refactor block entity handling and add support for invisible materials

This commit is contained in:
2026-06-21 14:22:10 +02:00
parent f1844a9dd9
commit c5d5eae1c1
18 changed files with 1541 additions and 107 deletions
@@ -0,0 +1,107 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import java.util.ArrayList;
import java.util.List;
/**
* Maps a {@link BlockEntityState} to its bundled CEM model name and ordered texture-path candidates.
* Block-entity geometry ships in the same CEM set as mobs (chest/sign/banner/…); textures live under
* {@code assets/minecraft/textures/entity/}. Mirrors the role {@link EntityModels} plays for mobs.
*/
public final class BlockEntityModels {
private BlockEntityModels() {}
/** The CEM ({@code .jem}) model name for a block-entity. */
public static String cemModel(BlockEntityState s) {
return switch (s.kind()) {
case CHEST, TRAPPED_CHEST, ENDER_CHEST -> switch (s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind()) {
case LEFT -> "chest_left";
case RIGHT -> "chest_right";
case SINGLE -> "chest";
};
case SIGN -> "sign";
case WALL_SIGN -> "wall_sign";
case HANGING_SIGN -> "hanging_sign";
case BANNER, WALL_BANNER -> "banner";
case BED -> "bed";
case SHULKER_BOX -> "shulker_box";
case CONDUIT -> "conduit";
case DECORATED_POT -> "decorated_pot";
case BELL -> "bell";
case HEAD, WALL_HEAD -> headModel(s.headType());
};
}
private static String headModel(String headType) {
if (headType == null) return "head";
return switch (headType) {
case "dragon" -> "head_dragon";
case "piglin" -> "head_piglin";
case "player" -> "head_player";
case "wither_skeleton" -> "wither_skull";
default -> "head"; // skeleton, zombie, creeper share the plain humanoid skull box
};
}
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List<ResourceLocation> textureCandidates(BlockEntityState s) {
List<String> paths = new ArrayList<>();
switch (s.kind()) {
case CHEST -> chestTextures(paths, s, "normal");
case TRAPPED_CHEST -> chestTextures(paths, s, "trapped");
case ENDER_CHEST -> paths.add("entity/chest/ender");
case SIGN, WALL_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/" + wood);
paths.add("entity/sign"); // legacy single-texture fallback
}
case HANGING_SIGN -> {
String wood = s.wood() == null ? "oak" : s.wood();
paths.add("entity/signs/hanging/" + wood);
}
case BANNER, WALL_BANNER -> paths.add("entity/banner/banner_base"); // tinted by baseColorArgb
case BED -> {
String c = s.colorName() == null ? "red" : s.colorName();
paths.add("entity/bed/" + c);
}
case SHULKER_BOX -> {
if (s.colorName() != null) paths.add("entity/shulker/shulker_" + s.colorName());
paths.add("entity/shulker/shulker"); // uncoloured (purpur) default
}
case CONDUIT -> paths.add("entity/conduit/base");
case DECORATED_POT -> paths.add("entity/decorated_pot/decorated_pot_base");
case BELL -> paths.add("entity/bell/bell_body");
case HEAD, WALL_HEAD -> headTextures(paths, s.headType());
}
List<ResourceLocation> out = new ArrayList<>(paths.size());
for (String p : paths) out.add(ResourceLocation.parse(p));
return out;
}
private static void chestTextures(List<String> paths, BlockEntityState s, String base) {
BlockEntityState.ChestKind ck = s.chestKind() == null ? BlockEntityState.ChestKind.SINGLE : s.chestKind();
switch (ck) {
case LEFT -> paths.add("entity/chest/" + base + "_left");
case RIGHT -> paths.add("entity/chest/" + base + "_right");
case SINGLE -> paths.add("entity/chest/" + base);
}
paths.add("entity/chest/" + base); // fallback to the single texture
}
private static void headTextures(List<String> paths, String headType) {
if (headType == null) { paths.add("entity/skeleton/skeleton"); return; }
switch (headType) {
case "skeleton" -> paths.add("entity/skeleton/skeleton");
case "wither_skeleton" -> paths.add("entity/skeleton/wither_skeleton");
case "zombie" -> paths.add("entity/zombie/zombie");
case "creeper" -> paths.add("entity/creeper/creeper");
case "piglin" -> paths.add("entity/piglin/piglin");
case "dragon" -> paths.add("entity/enderdragon/dragon");
case "player" -> paths.add("entity/player/wide/steve"); // skin handled separately by the baker
default -> paths.add("entity/skeleton/skeleton");
}
}
}
@@ -0,0 +1,46 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
* Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
* off-thread. Block-entities (chests, signs, banners, beds, heads, …) use vanilla {@code builtin/entity}
* block models with no geometry; their real shape lives in the bundled CEM models, so they are rendered
* through the same baking/intersection path as mobs. Bukkit-free so the baker needs no world access.
*
* <p>{@code facingDeg} is the world yaw the model should face (already converted from the block's
* facing/rotation, vanilla convention). Type-specific fields are null/0/empty when unused.
*/
public record BlockEntityState(
Kind kind,
int bx, int by, int bz,
float facingDeg,
ChestKind chestKind, // double-chest half (CHEST/TRAPPED_CHEST/ENDER_CHEST)
int baseColorArgb, // banner base tint (white texture); 0 = none
String colorName, // bed/shulker/banner colour variant ("red", "white", …); null = default
String wood, // sign/hanging-sign wood ("oak", "spruce", …); null = default
BedPart bedPart, // bed half
String headType, // "skeleton","wither_skeleton","zombie","creeper","piglin","dragon","player"
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
BellAttach bellAttach // bell attachment; null when not a bell
) {
public enum Kind {
CHEST, TRAPPED_CHEST, ENDER_CHEST,
SIGN, WALL_SIGN, HANGING_SIGN,
BANNER, WALL_BANNER,
BED, SHULKER_BOX,
HEAD, WALL_HEAD,
CONDUIT, DECORATED_POT, BELL
}
public enum ChestKind { SINGLE, LEFT, RIGHT }
public enum BedPart { HEAD, FOOT }
public enum BellAttach { FLOOR, CEILING, SINGLE_WALL, DOUBLE_WALL }
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
public record BannerPattern(String patternKey, int colorArgb) {}
}
@@ -0,0 +1,168 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import java.util.ArrayList;
import java.util.List;
/**
* Bakes flat wall decorations (paintings, item frames) into world-space {@link EntityCube}s. These are
* not CEM models but simple textured quads, so the geometry is built directly from the captured world
* bounding box and the front-facing direction. Added to the {@link EntityScene} like any other entity.
*/
public final class DecorationBaker {
private static final double MIN_THICKNESS = 0.0625; // 1px, so the slab test never degenerates
private final TextureCache textures;
public DecorationBaker(TextureCache textures) {
this.textures = textures;
}
public RenderedEntity bake(DecorationState s) {
return s.kind() == DecorationState.Kind.PAINTING ? bakePainting(s) : bakeItemFrame(s);
}
private RenderedEntity bakePainting(DecorationState s) {
int[][] art = textures.get(ResourceLocation.parse("painting/" + s.paintingArt())).orElse(null);
if (art == null) return null;
int[][] back = textures.get(ResourceLocation.parse("painting/back")).orElse(art);
double[] from = {s.minX(), s.minY(), s.minZ()};
double[] to = {s.maxX(), s.maxY(), s.maxZ()};
thicken(from, to, s.facing());
Face[] faces = new Face[6];
Direction front = direction(s.facing());
faces[front.ordinal()] = frontFace(art, s.facing());
faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return scene(List.of(cube));
}
/**
* Item frames render as the vanilla geometry: a 12x12 birch border, a recessed 10x10 leather back
* panel ({@code block/item_frame}), and the held item as a centred 8x8 sprite. Built in a canonical
* local frame (front toward +Z, centred on the block face) and oriented to the wall by an affine.
*/
private RenderedEntity bakeItemFrame(DecorationState s) {
String frameTex = s.glow() ? "block/glow_item_frame" : "block/item_frame";
int[][] leather = textures.get(ResourceLocation.parse(frameTex)).orElse(null);
int[][] wood = textures.get(ResourceLocation.parse("block/birch_planks")).orElse(leather);
if (leather == null) return null;
Affine toWorld = Affine.translation(faceCenterX(s), faceCenterY(s), faceCenterZ(s)).mul(facingRotation(s.facing()));
int si = Direction.SOUTH.ordinal();
List<EntityCube> cubes = new ArrayList<>(3);
// Wood border 12x12 (behind), leather back 10x10 (1px proud), item 8x8 (in front). Front = local +Z.
if (wood != null) {
Face[] f = new Face[6];
f[si] = new Face(wood, 1, 0, 0, 1, 0, -1); // tileable; flip matches the others
cubes.add(new EntityCube(px(-6, -6, 0), px(6, 6, 1), f, toWorld));
}
Face[] back = new Face[6];
back[si] = new Face(leather, 13.0 / 16, 3.0 / 16, 3.0 / 16, 13.0 / 16, 0, -1); // centre 10x10, flipped
cubes.add(new EntityCube(px(-5, -5, 1), px(5, 5, 2), back, toWorld));
if (s.itemId() != null) {
int[][] item = resolveItem(s.itemId());
if (item != null) {
int rot = ((Math.round(s.itemRotationDeg() / 90f) * 90) % 360 + 360) % 360;
Face[] f = new Face[6];
f[si] = new Face(item, 1, 0, 0, 1, rot, -1); // full sprite, flipped to read upright
cubes.add(new EntityCube(px(-4, -4, 2), px(4, 4, 3), f, toWorld));
}
}
return scene(cubes);
}
/** A local-space corner in model pixels (1/16 block); z is the outward (front) offset from the wall. */
private static double[] px(double x, double y, double z) {
return new double[]{x / 16.0, y / 16.0, z / 16.0};
}
/** Item sprite: generated items live under item/, block items fall back to block/. */
private int[][] resolveItem(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null);
if (t != null) return t;
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null);
}
// Frame placement: in-plane centre from the bbox, depth at the outward wall surface.
private static double faceCenterX(DecorationState s) {
return s.facing().axis() == 0 ? (s.facing().sign() > 0 ? s.maxX() : s.minX()) : (s.minX() + s.maxX()) / 2;
}
private static double faceCenterY(DecorationState s) {
return s.facing().axis() == 1 ? (s.facing().sign() > 0 ? s.maxY() : s.minY()) : (s.minY() + s.maxY()) / 2;
}
private static double faceCenterZ(DecorationState s) {
return s.facing().axis() == 2 ? (s.facing().sign() > 0 ? s.maxZ() : s.minZ()) : (s.minZ() + s.maxZ()) / 2;
}
/** Maps the canonical local frame (front +Z, up +Y) onto the wall facing the given direction. */
private static Affine facingRotation(DecorationState.Facing f) {
return switch (f) {
case SOUTH -> Affine.identity();
case NORTH -> Affine.rotY(Math.PI);
case EAST -> Affine.rotY(Math.PI / 2);
case WEST -> Affine.rotY(-Math.PI / 2);
case UP -> Affine.rotX(-Math.PI / 2);
case DOWN -> Affine.rotX(Math.PI / 2);
};
}
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
private Face frontFace(int[][] tex, DecorationState.Facing facing) {
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
return new Face(tex, u1, 0, u2, 1, 0, -1);
}
private void thicken(double[] from, double[] to, DecorationState.Facing facing) {
int a = facing.axis();
if (to[a] - from[a] >= MIN_THICKNESS) return;
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
else to[a] = from[a] + MIN_THICKNESS;
}
private static Direction direction(DecorationState.Facing f) {
return switch (f) {
case NORTH -> Direction.NORTH;
case SOUTH -> Direction.SOUTH;
case EAST -> Direction.EAST;
case WEST -> Direction.WEST;
case UP -> Direction.UP;
case DOWN -> Direction.DOWN;
};
}
private static Direction opposite(Direction d) {
return switch (d) {
case NORTH -> Direction.SOUTH;
case SOUTH -> Direction.NORTH;
case EAST -> Direction.WEST;
case WEST -> Direction.EAST;
case UP -> Direction.DOWN;
case DOWN -> Direction.UP;
};
}
private static RenderedEntity scene(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -0,0 +1,41 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
* Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit
* {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM
* models, so they have their own state/baker. The world-space bounding box is captured directly (it
* already encodes vanilla's awkward multi-block painting offset), avoiding placement math.
*/
public record DecorationState(
Kind kind,
double minX, double minY, double minZ,
double maxX, double maxY, double maxZ,
Facing facing, // direction the front faces (away from the wall)
String paintingArt, // painting art asset key (texture painting/<art>); null for frames
String itemId, // item-frame contents material key (e.g. "diamond"); null if empty
int itemRotationDeg, // item-frame content rotation (0/45/…/315)
boolean glow // glow item frame
) {
public enum Kind { PAINTING, ITEM_FRAME }
public enum Facing {
NORTH, SOUTH, EAST, WEST, UP, DOWN;
/** The axis this face's normal lies on: 0=x, 1=y, 2=z. */
public int axis() {
return switch (this) {
case EAST, WEST -> 0;
case UP, DOWN -> 1;
case NORTH, SOUTH -> 2;
};
}
/** +1 or -1 along {@link #axis()}. */
public int sign() {
return switch (this) {
case SOUTH, UP, EAST -> 1;
case NORTH, DOWN, WEST -> -1;
};
}
}
}
@@ -1,5 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
@@ -7,8 +8,10 @@ import java.util.ArrayList;
import java.util.List;
/**
* All baked entities for one render. Provides the nearest entity hit along a ray, using per-entity
* and per-cube AABB broad-phase culling. Immutable after construction → safe for the parallel tracer.
* All baked entities (mobs + block-entities) for one render. Provides the nearest hit along a ray,
* using per-entity and per-cube AABB broad-phase culling. Block-entities are baked into the same list
* and tested identically, so the raytracer needs no special case. Immutable after construction → safe
* for the parallel tracer.
*/
public final class EntityScene {
@@ -16,11 +19,29 @@ public final class EntityScene {
private final List<RenderedEntity> entities;
public EntityScene(List<EntityState> states, CemBaker baker) {
this.entities = new ArrayList<>(states.size());
this(states, baker, List.of(), null, List.of(), null);
}
public EntityScene(List<EntityState> states, CemBaker baker,
List<BlockEntityState> blockEntities, BlockEntityBaker beBaker,
List<DecorationState> decorations, DecorationBaker decoBaker) {
this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size());
for (EntityState s : states) {
RenderedEntity e = baker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
if (beBaker != null) {
for (BlockEntityState s : blockEntities) {
RenderedEntity e = beBaker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
}
if (decoBaker != null) {
for (DecorationState s : decorations) {
RenderedEntity e = decoBaker.bake(s);
if (e != null && !e.cubes.isEmpty()) entities.add(e);
}
}
}
public boolean isEmpty() {
@@ -0,0 +1,236 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Bakes a {@link BlockEntityState} into world-space cubes using the bundled CEM models, reusing
* {@link CemGeometry}. Unlike mobs, block-entities are not dropped to the ground by their model bounds;
* each type is placed by an affine that reproduces the vanilla BlockEntityRenderer: the block cell
* centre, a yaw from the block's facing/rotation, and a per-type local offset/scale.
*
* <p>The CEM block-entity models are authored centred on X/Z with their base near Y=0 (after the px→
* block scale), so the placement is {@code T(cell centre) · rotY(yaw) · T(localOffset)} and most types
* only need defaults. Wall-mounted and scaled types (signs, wall heads, beds) override via {@link Place}.
*/
public final class BlockEntityBaker {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
this.models = models;
this.textures = textures;
this.skins = skins;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
public RenderedEntity bake(BlockEntityState s) {
List<Layer> layers = layers(s);
if (layers.isEmpty()) return null;
// Head model depends on the texture aspect (skeleton 64x32 vs zombie/player 64x64), so resolve it
// from the chosen texture rather than statically.
CemModelLoader.CemModel model = models.get(modelName(s, layers.get(0).tex));
if (model == null) return null;
Place p = place(s);
Affine pre = Affine.scale(p.scale / 16.0);
Affine placement = Affine.translation(s.bx() + 0.5, s.by(), s.bz() + 0.5)
.mul(Affine.rotY(Math.toRadians(p.yaw)))
.mul(Affine.translation(p.lx, p.ly, p.lz));
List<EntityCube> cubes = new ArrayList<>();
for (Layer layer : layers) {
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, layer.tex, pre, layer.hidden, layer.texW, layer.texH, layer.boxUv)) {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
return cubes.isEmpty() ? null : CemGeometry.finish(cubes);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
private String modelName(BlockEntityState s, int[][] tex) {
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
return BlockEntityModels.cemModel(s);
}
String t = s.headType();
if ("dragon".equals(t)) return "head_dragon";
if ("piglin".equals(t)) return "head_piglin";
// Square textures (zombie, player) carry a hat overlay → use the 64x64 player-head model; the
// 2:1 skull textures (skeleton, wither_skeleton, creeper) use the 64x32 skull model.
boolean square = tex.length > 0 && tex.length == tex[0].length;
return square ? "head_player" : "head";
}
/** A single bake pass: one texture applied to the parts not in {@code hidden}, with optional UV size
* and a flag forcing box-UV (for standalone part textures not matching the model's per-face UV). */
private record Layer(int[][] tex, Set<String> hidden, int texW, int texH, boolean boxUv) {
Layer(int[][] tex, Set<String> hidden) { this(tex, hidden, 0, 0, false); }
}
/** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */
private List<Layer> layers(BlockEntityState s) {
if (s.kind() == BlockEntityState.Kind.DECORATED_POT) {
return potLayers(s);
}
if (s.kind() == BlockEntityState.Kind.CONDUIT) {
return conduitLayers();
}
int[][] tex = resolveTexture(s);
return tex == null ? List.of() : List.of(new Layer(tex, hiddenParts(s)));
}
private static final Set<String> CONDUIT_PARTS = Set.of("eye", "cage", "base", "wind");
/** The conduit's cage and inner heart use separate textures; the eye/wind (active state) are skipped. */
private List<Layer> conduitLayers() {
int[][] cage = textures.get(ResourceLocation.parse("entity/conduit/cage")).orElse(null);
int[][] base = textures.get(ResourceLocation.parse("entity/conduit/base")).orElse(null);
// Each part's texture is authored at its own native size (32x16) with a box-UV layout, so force
// box-UV and normalise by the texture's own size (the model's per-face UV assumes a combined sheet).
List<Layer> layers = new ArrayList<>(2);
if (cage != null) layers.add(new Layer(cage, onlyPart("cage", CONDUIT_PARTS), cage[0].length, cage.length, true));
if (base != null) layers.add(new Layer(base, onlyPart("base", CONDUIT_PARTS), base[0].length, base.length, true));
return layers;
}
/** Hidden set that leaves only {@code keep} visible out of {@code all}. */
private static Set<String> onlyPart(String keep, Set<String> all) {
Set<String> hidden = new java.util.HashSet<>(all);
hidden.remove(keep);
return hidden;
}
private static final Set<String> POT_PARTS = Set.of("neck", "top", "bottom", "front", "back", "left", "right");
private static final String[] POT_FACES = {"front", "left", "right", "back"}; // matches sherd capture order
private List<Layer> potLayers(BlockEntityState s) {
int[][] base = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_base")).orElse(null);
int[][] side = textures.get(ResourceLocation.parse("entity/decorated_pot/decorated_pot_side")).orElse(null);
if (base == null) return List.of();
List<Layer> layers = new ArrayList<>();
// Structure (rim/neck/foot) comes from the combined base texture; the four sides are NOT in it.
layers.add(new Layer(base, new java.util.HashSet<>(java.util.List.of("front", "back", "left", "right"))));
// Each side: its sherd pattern if set, else the plain brick side. The model's per-face UV maps the
// centre of the 16x16 texture onto the face (centred, edges intact).
for (int i = 0; i < POT_FACES.length; i++) {
int[][] tex = side;
if (i < s.sherds().size()) {
String sherd = s.sherds().get(i);
if (sherd != null && sherd.endsWith("_pottery_sherd")) {
int[][] pat = textures.get(ResourceLocation.parse(
"entity/decorated_pot/" + sherd.replace("_pottery_sherd", "_pottery_pattern"))).orElse(null);
if (pat != null) tex = pat;
}
}
if (tex == null) continue;
layers.add(new Layer(tex, onlyPart(POT_FACES[i], POT_PARTS)));
}
return layers;
}
// --- placement parameters per type ---
/** Local placement: applied yaw (deg), model scale, and a local-frame offset (blocks). */
private record Place(double yaw, double scale, double lx, double ly, double lz) {}
private Place place(BlockEntityState s) {
double yaw = 180 - s.facingDeg(); // model default faces north; rotate by the block's facing
return switch (s.kind()) {
case SIGN -> new Place(yaw, SIGN_SCALE, 0, 0, 0);
case WALL_SIGN -> new Place(yaw, SIGN_SCALE, 0, -0.3125, 0.4375); // drop to mid-block, push to wall
case HANGING_SIGN -> new Place(yaw, 1.0, 0, 0, 0);
case WALL_HEAD -> new Place(yaw, 1.0, 0, 0.25, 0.25); // mid-height, against the wall
case WALL_BANNER -> new Place(yaw, 1.0, 0, -0.16, 0.4375);
default -> new Place(yaw, 1.0, 0, 0, 0);
};
}
/** Parts to omit (the unused bed half / its legs, the conduit's open-state shell). */
private Set<String> hiddenParts(BlockEntityState s) {
return switch (s.kind()) {
case BED -> s.bedPart() == BlockEntityState.BedPart.HEAD
? Set.of("foot", "leg3", "leg4")
: Set.of("head", "leg1", "leg2");
case CONDUIT -> Set.of("eye", "wind");
default -> Set.of();
};
}
// --- texture resolution ---
private int[][] resolveTexture(BlockEntityState s) {
// Player heads use the owner's skin when available.
if (s.skinUrl() != null && (s.kind() == BlockEntityState.Kind.HEAD || s.kind() == BlockEntityState.Kind.WALL_HEAD)) {
int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin;
}
boolean isBanner = s.kind() == BlockEntityState.Kind.BANNER || s.kind() == BlockEntityState.Kind.WALL_BANNER;
for (ResourceLocation rl : BlockEntityModels.textureCandidates(s)) {
int[][] t = textures.get(rl).orElse(null);
if (t == null) continue;
if (isBanner) return bakeBanner(t, s);
return t;
}
return null;
}
/**
* Composites a banner texture: tint the white {@code banner_base} cloth with the base colour, then
* alpha-overlay each pattern mask ({@code entity/banner/<key>}) dyed with its own colour, in order.
*/
private int[][] bakeBanner(int[][] base, BlockEntityState s) {
int[][] out = deepCopy(base);
if (s.baseColorArgb() != 0) CemGeometry.tint(out, s.baseColorArgb());
for (BlockEntityState.BannerPattern pat : s.patterns()) {
int[][] mask = textures.get(ResourceLocation.parse("entity/banner/" + pat.patternKey())).orElse(null);
if (mask == null) continue;
int[][] dyed = deepCopy(mask);
CemGeometry.tint(dyed, pat.colorArgb());
overlay(out, dyed);
}
return out;
}
/** Standard src-over alpha composite of {@code src} onto {@code dst} (same dimensions), in place. */
private static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) {
int sp = src[y][x];
int sa = (sp >>> 24) & 0xFF;
if (sa == 0) continue;
if (sa == 255) { dst[y][x] = sp; continue; }
int dp = dst[y][x];
int da = (dp >>> 24) & 0xFF;
int outA = sa + da * (255 - sa) / 255;
int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out;
}
}
@@ -40,19 +40,6 @@ public final class CemBaker {
this.skins = skins;
}
private record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
double m = Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
m = Math.min(m, world.apply(x, y, z)[1]);
}
return m;
}
}
public RenderedEntity bake(EntityState s) {
int[][] tex = resolveTexture(s);
CemModelLoader.CemModel model = models.get(EntityModels.cemModel(s.typeKey()));
@@ -64,8 +51,7 @@ public final class CemBaker {
Affine pre = Affine.scale(sc / 16.0);
java.util.Set<String> hidden = HIDDEN_PARTS.getOrDefault(EntityModels.cemModel(s.typeKey()), java.util.Set.of());
List<Baked> baked = new ArrayList<>();
bakeModel(model, tex, pre, hidden, baked);
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
@@ -73,8 +59,8 @@ public final class CemBaker {
if (wool != null && woolTex != null) {
int[][] t = new int[woolTex.length][];
for (int y = 0; y < woolTex.length; y++) t[y] = woolTex[y].clone();
if (s.tint() != 0) tint(t, s.tint());
bakeModel(wool, t, pre, hidden, baked);
if (s.tint() != 0) CemGeometry.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
@@ -83,62 +69,20 @@ public final class CemBaker {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true, new double[]{0,0,0}, new double[]{0,0,0});
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
if (baked.isEmpty()) return fallbackBox(s, tex);
double minY = Double.MAX_VALUE;
for (Baked b : baked) minY = Math.min(minY, b.minWorldY());
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.translation(0, -minY, 0));
List<EntityCube> cubes = new ArrayList<>(baked.size());
for (Baked b : baked) cubes.add(new EntityCube(b.from, b.to, b.faces, place.mul(b.world)));
return finish(cubes);
}
private void bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre,
java.util.Set<String> hidden, List<Baked> out) {
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, model, tex, out);
}
}
/**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
java.util.Set<String> hidden, CemModelLoader.CemModel model, int[][] tex, List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0,0,0}, new double[]{0,0,0});
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
out.add(new Baked(from, to, faces, world));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, model, tex, out);
}
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
return CemGeometry.finish(cubes);
}
// --- texture resolution (player skin, dyed sheep wool, variant candidates) ---
@@ -170,32 +114,7 @@ public final class CemBaker {
.mul(Affine.scale(1.0 / 16.0));
List<EntityCube> cubes = new ArrayList<>();
cubes.add(new EntityCube(from, to, faces, place));
return finish(cubes);
}
private RenderedEntity finish(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
private static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
return CemGeometry.finish(cubes);
}
private static int[][] flat(int argb) {
@@ -0,0 +1,132 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Shared, stateless CEM geometry baking used by both {@link CemBaker} (mobs) and the block-entity
* baker. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube +
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
*/
final class CemGeometry {
private CemGeometry() {}
/** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */
record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() {
double m = Double.MAX_VALUE;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
m = Math.min(m, world.apply(x, y, z)[1]);
}
return m;
}
}
/** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bakeModel(model, tex, pre, hidden, 0, 0);
}
/**
* As {@link #bakeModel(CemModelLoader.CemModel, int[][], Affine, Set)} but with an explicit texture
* size for UV normalisation (use when the applied texture's size differs from the model's declared
* {@code textureSize} in a non-proportional way, e.g. a 16x16 sherd on a 32x32-authored pot face).
* {@code texW}/{@code texH} of 0 fall back to the model's declared size.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH) {
return bakeModel(model, tex, pre, hidden, texW, texH, false);
}
/**
* As above, but {@code ignoreFaceUv} forces box-UV even when the model declares per-face UV — used when
* applying a standalone texture (e.g. the conduit cage's own {@code cage.png}) whose layout is box-UV,
* not the combined-sheet layout the model's per-face UV assumes.
*/
static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden,
int texW, int texH, boolean ignoreFaceUv) {
int nw = texW > 0 ? texW : model.texW();
int nh = texH > 0 ? texH : model.texH();
List<Baked> out = new ArrayList<>();
for (CemModelLoader.CemPart p : model.parts()) {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, hidden, nw, nh, tex, ignoreFaceUv, out);
}
return out;
}
/**
* Faithful OptiFine/Blockbench CEM transform: each part is a group whose rotation pivots around its
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth,
Set<String> hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List<Baked> out) {
if (hidden.contains(part.name())) return;
Affine world = parentWorld
.mul(Affine.translation(o[0], o[1], o[2]))
.mul(Affine.rotZ(Math.toRadians(part.rotate()[2])))
.mul(Affine.rotY(Math.toRadians(part.rotate()[1])))
.mul(Affine.rotX(Math.toRadians(part.rotate()[0])))
.mul(Affine.translation(-o[0], -o[1], -o[2]));
double ox = depth > 0 ? o[0] : 0, oy = depth > 0 ? o[1] : 0, oz = depth > 0 ? o[2] : 0;
for (CemModelLoader.CemBox b : part.boxes()) {
double inf = b.inflate();
double[] org = {b.origin()[0] + ox, b.origin()[1] + oy, b.origin()[2] + oz};
double[] from = {org[0] - inf, org[1] - inf, org[2] - inf};
double[] to = {org[0] + b.size()[0] + inf, org[1] + b.size()[1] + inf, org[2] + b.size()[2] + inf};
double[][] faceUv = ignoreFaceUv ? null : b.faceUv();
ModelCube mc = new ModelCube(org, b.size(), inf, b.uv(), b.mirror(), new double[]{0, 0, 0}, new double[]{0, 0, 0}, faceUv);
Face[] faces = BoxUv.build(mc, tex, texW, texH);
out.add(new Baked(from, to, faces, world));
}
for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = depth >= 1 ? new double[]{t[0] + o[0], t[1] + o[1], t[2] + o[2]} : new double[]{t[0], t[1], t[2]};
bakePart(child, world, co, depth + 1, hidden, texW, texH, tex, ignoreFaceUv, out);
}
}
/** Multiplies every non-transparent texel by an ARGB tint (in place). */
static void tint(int[][] tex, int argb) {
int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
row[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
/** Wraps baked world-space cubes into a {@link RenderedEntity}, computing the overall AABB. */
static RenderedEntity finish(List<EntityCube> cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
for (EntityCube c : cubes) {
for (int a = 0; a < 3; a++) {
if (c.aabbMin[a] < min[a]) min[a] = c.aabbMin[a];
if (c.aabbMax[a] > max[a]) max[a] = c.aabbMax[a];
}
}
return new RenderedEntity(cubes, min, max);
}
}
@@ -24,8 +24,12 @@ import java.util.logging.Logger;
*/
public final class CemModelLoader {
/** A box: absolute min corner + size (px), inflate, box-UV offset (texels), and horizontal texture mirror. */
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) {}
/**
* A box: absolute min corner + size (px), inflate, box-UV offset (texels), horizontal texture mirror,
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
*/
public record CemBox(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {}
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
@@ -81,7 +85,7 @@ public final class CemModelLoader {
double[] uv = b.has("textureOffset")
? new double[]{b.getAsJsonArray("textureOffset").get(0).getAsDouble(), b.getAsJsonArray("textureOffset").get(1).getAsDouble()}
: new double[]{0, 0};
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b)));
boxes.add(new CemBox(origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b)));
}
}
List<CemPart> children = new ArrayList<>();
@@ -91,6 +95,25 @@ public final class CemModelLoader {
return new CemPart(name, translate, rotate, boxes, children);
}
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST).
private static final String[] FACE_UV_KEYS = {"uvDown", "uvUp", "uvNorth", "uvSouth", "uvWest", "uvEast"};
/** Parses per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) into {@code {x,y,w,h}}, or null. */
private static double[][] parseFaceUv(JsonObject b) {
boolean any = false;
for (String k : FACE_UV_KEYS) if (b.has(k)) { any = true; break; }
if (!any) return null;
double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) {
if (!b.has(FACE_UV_KEYS[i])) continue;
JsonArray a = b.getAsJsonArray(FACE_UV_KEYS[i]);
double u1 = a.get(0).getAsDouble(), v1 = a.get(1).getAsDouble();
double u2 = a.get(2).getAsDouble(), v2 = a.get(3).getAsDouble();
faces[i] = new double[]{u1, v1, u2 - u1, v2 - v1};
}
return faces;
}
private static boolean mirrorsU(JsonObject o) {
return o.has("mirrorTexture") && o.get("mirrorTexture").getAsString().contains("u");
}
@@ -2,12 +2,18 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityScene;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
import eu.mhsl.minecraft.pixelpics.render.snapshot.BlockEntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.DecorationSnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.EntitySnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.SnapshotBuilder;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
@@ -45,13 +51,18 @@ public class DefaultScreenRenderer implements Renderer {
private final SnapshotRaytracer raytracer;
private final CemBaker entityBaker;
private final BlockEntityBaker blockEntityBaker;
private final DecorationBaker decorationBaker;
private final Logger logger;
public DefaultScreenRenderer(BlockModelRegistry registry, BiomeTintProvider tintProvider,
TextureCache textures, CemBaker entityBaker, Logger logger) {
TextureCache textures, CemBaker entityBaker,
BlockEntityBaker blockEntityBaker, Logger logger) {
SkyRenderer skyRenderer = new SkyRenderer(textures);
this.raytracer = new SnapshotRaytracer(registry, tintProvider, skyRenderer, MAX_DISTANCE, REFLECTION_DEPTH);
this.entityBaker = entityBaker;
this.blockEntityBaker = blockEntityBaker;
this.decorationBaker = new DecorationBaker(textures);
this.logger = logger;
}
@@ -68,6 +79,8 @@ public class DefaultScreenRenderer implements Renderer {
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
List<EntityState> entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
World world = eyeLocation.getWorld();
long dayTime = world.getTime();
@@ -76,7 +89,7 @@ public class DefaultScreenRenderer implements Renderer {
SkyContext sky = new SkyContext(dayTime, moonPhase, fullTime);
return new RenderJob(snapshot, rayMap, eyeLocation.toVector(),
resolution.getWidth(), resolution.getHeight(), sky, entities);
resolution.getWidth(), resolution.getHeight(), sky, entities, blockEntities, decorations);
}
/** Traces every (super)ray in parallel, then downsamples gamma-correctly. Safe off the main thread. */
@@ -88,7 +101,8 @@ public class DefaultScreenRenderer implements Renderer {
WorldSnapshot snapshot = job.snapshot();
Vector origin = job.origin();
SkyContext sky = job.sky();
EntityScene scene = new EntityScene(job.entities(), entityBaker);
EntityScene scene = new EntityScene(job.entities(), entityBaker, job.blockEntities(), blockEntityBaker,
job.decorations(), decorationBaker);
int[] superBuf = new int[rayMap.size()];
IntStream.range(0, rayMap.size()).parallel().forEach(i ->
@@ -1,5 +1,7 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
@@ -8,10 +10,24 @@ import org.bukkit.util.Vector;
import java.util.List;
/**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera
* origin, the sky context (time of day / moon phase) and the captured entity states.
* {@link DefaultScreenRenderer#execute} can run this off the main thread.
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/
public record RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<DecorationState> decorations) {
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(WorldSnapshot snapshot, List<Vector> rayMap, Vector origin,
int width, int height, SkyContext sky, List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
}
@@ -0,0 +1,327 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BannerPattern;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BedPart;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.BellAttach;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.ChestKind;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.Kind;
import org.bukkit.Color;
import org.bukkit.DyeColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Banner;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DecoratedPot;
import org.bukkit.block.Skull;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Captures block-entities (chests, signs, banners, beds, heads, …) near the view frustum into immutable
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the
* camera's bounding box.
*/
public final class BlockEntitySnapshotBuilder {
private BlockEntitySnapshotBuilder() {}
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) {
World world = eye.getWorld();
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
// 1-block margin so block-entities straddling the frustum edge are still captured.
int bMinX = (int) Math.floor(minX) - 1, bMaxX = (int) Math.ceil(maxX) + 1;
int bMinY = (int) Math.floor(minY) - 1, bMaxY = (int) Math.ceil(maxY) + 1;
int bMinZ = (int) Math.floor(minZ) - 1, bMaxZ = (int) Math.ceil(maxZ) + 1;
int minCX = bMinX >> 4, maxCX = bMaxX >> 4, minCZ = bMinZ >> 4, maxCZ = bMaxZ >> 4;
List<BlockEntityState> out = new ArrayList<>();
for (int cx = minCX; cx <= maxCX; cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) {
if (!world.isChunkLoaded(cx, cz)) continue;
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
if (bx < bMinX || bx > bMaxX || by < bMinY || by > bMaxY || bz < bMinZ || bz > bMaxZ) continue;
try {
BlockEntityState s = classify(ts);
if (s != null) out.add(s);
} catch (Throwable ignored) {
// Unsupported on this server version / odd state — skip this one.
}
}
}
}
return out;
}
private static BlockEntityState classify(BlockState ts) {
Material mat = ts.getType();
String n = mat.name();
BlockData data = ts.getBlockData();
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
// --- chests ---
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST) {
Kind kind = mat == Material.TRAPPED_CHEST ? Kind.TRAPPED_CHEST
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST;
ChestKind ck = ChestKind.SINGLE;
if (data instanceof org.bukkit.block.data.type.Chest cd) {
ck = switch (cd.getType()) {
case LEFT -> ChestKind.LEFT;
case RIGHT -> ChestKind.RIGHT;
case SINGLE -> ChestKind.SINGLE;
};
}
return base(kind, bx, by, bz, facingYaw(data)).chestKind(ck).build();
}
// --- beds ---
if (data instanceof org.bukkit.block.data.type.Bed bed) {
BedPart part = bed.getPart() == org.bukkit.block.data.type.Bed.Part.HEAD ? BedPart.HEAD : BedPart.FOOT;
return base(Kind.BED, bx, by, bz, faceToYaw(bed.getFacing()))
.bedPart(part).colorName(stripColor(n, "_BED")).build();
}
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX");
return base(Kind.SHULKER_BOX, bx, by, bz, facingYaw(data)).colorName(color).build();
}
// --- banners ---
if (n.endsWith("_BANNER")) {
boolean wall = n.endsWith("_WALL_BANNER");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor();
b.baseColorArgb(dyeArgb(base));
List<BannerPattern> pats = new ArrayList<>();
for (Pattern p : banner.getPatterns()) {
String key = p.getPattern().key().value();
pats.add(new BannerPattern(key, dyeArgb(p.getColor())));
}
b.patterns(pats);
}
return b.build();
}
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
if (n.endsWith("_WALL_SIGN")) {
return base(Kind.WALL_SIGN, bx, by, bz, facingYaw(data)).wood(wood).build();
}
if (n.endsWith("_HANGING_SIGN")) {
boolean wall = n.endsWith("_WALL_HANGING_SIGN");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
return base(Kind.HANGING_SIGN, bx, by, bz, yaw).wood(wood).build();
}
return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build();
}
// --- heads / skulls ---
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) {
boolean wall = n.contains("_WALL_");
String headType = headType(n);
float yaw = wall ? facingYaw(data) : rotationYaw(data);
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType);
if ("player".equals(headType) && ts instanceof Skull skull) {
b.skinUrl(skinUrl(skull));
}
return b.build();
}
// --- conduit ---
if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0).build();
}
// --- decorated pot ---
if (mat == Material.DECORATED_POT) {
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
return b.build();
}
// --- bell ---
if (mat == Material.BELL) {
BellAttach attach = BellAttach.FLOOR;
if (data instanceof org.bukkit.block.data.type.Bell bd) {
attach = switch (bd.getAttachment()) {
case FLOOR -> BellAttach.FLOOR;
case CEILING -> BellAttach.CEILING;
case SINGLE_WALL -> BellAttach.SINGLE_WALL;
case DOUBLE_WALL -> BellAttach.DOUBLE_WALL;
};
}
return base(Kind.BELL, bx, by, bz, facingYaw(data)).bellAttach(attach).build();
}
return null;
}
// --- facing / rotation helpers ---
private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing());
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return 0;
}
private static float rotationYaw(BlockData data) {
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
if (data instanceof Directional d) return faceToYaw(d.getFacing());
return 0;
}
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
private static float faceToYaw(BlockFace face) {
Float y = FACE_YAW.get(face);
return y == null ? 0 : y;
}
private static final Map<BlockFace, Float> FACE_YAW = buildFaceYaw();
private static Map<BlockFace, Float> buildFaceYaw() {
Map<BlockFace, Float> m = new EnumMap<>(BlockFace.class);
m.put(BlockFace.SOUTH, 0f);
m.put(BlockFace.SOUTH_SOUTH_WEST, 22.5f);
m.put(BlockFace.SOUTH_WEST, 45f);
m.put(BlockFace.WEST_SOUTH_WEST, 67.5f);
m.put(BlockFace.WEST, 90f);
m.put(BlockFace.WEST_NORTH_WEST, 112.5f);
m.put(BlockFace.NORTH_WEST, 135f);
m.put(BlockFace.NORTH_NORTH_WEST, 157.5f);
m.put(BlockFace.NORTH, 180f);
m.put(BlockFace.NORTH_NORTH_EAST, 202.5f);
m.put(BlockFace.NORTH_EAST, 225f);
m.put(BlockFace.EAST_NORTH_EAST, 247.5f);
m.put(BlockFace.EAST, 270f);
m.put(BlockFace.EAST_SOUTH_EAST, 292.5f);
m.put(BlockFace.SOUTH_EAST, 315f);
m.put(BlockFace.SOUTH_SOUTH_EAST, 337.5f);
return m;
}
// --- data extraction helpers ---
private static String stripColor(String name, String suffix) {
return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT);
}
private static String signWood(String name) {
String s = name;
for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
if (s.endsWith(suf)) { s = s.substring(0, s.length() - suf.length()); break; }
}
return s.toLowerCase(java.util.Locale.ROOT);
}
private static String headType(String name) {
String s = name.replace("_WALL_", "_");
return switch (s) {
case "PLAYER_HEAD" -> "player";
case "ZOMBIE_HEAD" -> "zombie";
case "CREEPER_HEAD" -> "creeper";
case "DRAGON_HEAD" -> "dragon";
case "PIGLIN_HEAD" -> "piglin";
case "SKELETON_SKULL" -> "skeleton";
case "WITHER_SKELETON_SKULL" -> "wither_skeleton";
default -> "skeleton";
};
}
private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — matches the CEM decorated_pot face parts.
List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[]{
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(java.util.Locale.ROOT));
}
return out;
}
private static String skinUrl(Skull skull) {
try {
org.bukkit.profile.PlayerProfile profile = skull.getOwnerProfile();
if (profile != null && profile.getTextures().getSkin() != null) {
return profile.getTextures().getSkin().toString();
}
} catch (Throwable ignored) {
}
return null;
}
/** Opaque ARGB for a dye colour. */
private static int dyeArgb(DyeColor dye) {
if (dye == null) return 0xFFFFFFFF;
Color c = dye.getColor();
return 0xFF000000 | (c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue();
}
// --- small fluent builder to keep the 15-field record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw);
}
private static final class Builder {
private final Kind kind;
private final int bx, by, bz;
private final float yaw;
private ChestKind chestKind;
private int baseColorArgb;
private String colorName;
private String wood;
private BedPart bedPart;
private String headType;
private String skinUrl;
private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of();
private BellAttach bellAttach;
Builder(Kind kind, int bx, int by, int bz, float yaw) {
this.kind = kind; this.bx = bx; this.by = by; this.bz = bz; this.yaw = yaw;
}
Builder chestKind(ChestKind v) { this.chestKind = v; return this; }
Builder baseColorArgb(int v) { this.baseColorArgb = v; return this; }
Builder colorName(String v) { this.colorName = v; return this; }
Builder wood(String v) { this.wood = v; return this; }
Builder bedPart(BedPart v) { this.bedPart = v; return this; }
Builder headType(String v) { this.headType = v; return this; }
Builder skinUrl(String v) { this.skinUrl = v; return this; }
Builder patterns(List<BannerPattern> v) { this.patterns = v; return this; }
Builder sherds(List<String> v) { this.sherds = v; return this; }
Builder bellAttach(BellAttach v) { this.bellAttach = v; return this; }
BlockEntityState build() {
return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood,
bedPart, headType, skinUrl, patterns, sherds, bellAttach);
}
}
}
@@ -0,0 +1,90 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.DecorationState;
import org.bukkit.Location;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly,
* which already encodes vanilla's painting placement offsets.
*/
public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {}
public static List<DecorationState> build(Location eye, List<Vector> rayMap, double maxDistance) {
Vector o = eye.toVector();
double minX = o.getX(), minY = o.getY(), minZ = o.getZ();
double maxX = o.getX(), maxY = o.getY(), maxZ = o.getZ();
for (Vector ray : rayMap) {
minX = Math.min(minX, o.getX() + ray.getX() * maxDistance);
maxX = Math.max(maxX, o.getX() + ray.getX() * maxDistance);
minY = Math.min(minY, o.getY() + ray.getY() * maxDistance);
maxY = Math.max(maxY, o.getY() + ray.getY() * maxDistance);
minZ = Math.min(minZ, o.getZ() + ray.getZ() * maxDistance);
maxZ = Math.max(maxZ, o.getZ() + ray.getZ() * maxDistance);
}
Location center = new Location(eye.getWorld(), (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + 2, hy = (maxY - minY) / 2 + 2, hz = (maxZ - minZ) / 2 + 2;
Collection<Entity> nearby = eye.getWorld().getNearbyEntities(center, hx, hy, hz);
List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) {
try {
DecorationState s = toState(e);
if (s != null) out.add(s);
} catch (Throwable ignored) {
}
}
return out;
}
private static DecorationState toState(Entity e) {
if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox();
String art = painting.getArt().assetId().value();
return new DecorationState(DecorationState.Kind.PAINTING,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), art, null, 0, false);
}
if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox();
boolean glow = e instanceof org.bukkit.entity.GlowItemFrame
|| e.getType().getKey().getKey().equals("glow_item_frame");
String itemId = itemId(frame.getItem());
int rot = frame.getRotation().ordinal() * 45;
return new DecorationState(DecorationState.Kind.ITEM_FRAME,
bb.getMinX(), bb.getMinY(), bb.getMinZ(), bb.getMaxX(), bb.getMaxY(), bb.getMaxZ(),
facing(e.getFacing()), null, itemId, rot, glow);
}
return null;
}
/** The framed item's material key (e.g. "diamond"); the baker resolves item/ or block/ textures from it. */
private static String itemId(ItemStack item) {
if (item == null || item.getType().isAir()) return null;
return item.getType().getKey().getKey();
}
private static DecorationState.Facing facing(BlockFace face) {
return switch (face) {
case NORTH -> DecorationState.Facing.NORTH;
case SOUTH -> DecorationState.Facing.SOUTH;
case EAST -> DecorationState.Facing.EAST;
case WEST -> DecorationState.Facing.WEST;
case UP -> DecorationState.Facing.UP;
default -> DecorationState.Facing.DOWN;
};
}
}