diff --git a/build.gradle b/build.gradle index 754221b..04ed2b9 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,7 @@ repositories { } dependencies { - compileOnly("io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT") + compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") } def targetJavaVersion = 21 diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 9c93d44..4f84a64 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -67,8 +67,10 @@ public final class Main extends JavaPlugin { eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache(); eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache); + eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker = + new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache); - this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, getLogger()); + this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger()); // Warm the map palette on the main thread so off-thread dithering never triggers its first init. eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size(); getLogger().info("PixelPics renderer initialized with resource pack assets."); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java index 0b81ef7..ba1dd9e 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java @@ -8,6 +8,7 @@ import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel; import org.bukkit.Material; import org.bukkit.block.data.BlockData; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -43,6 +44,10 @@ public final class BlockModelRegistry { Material material = data.getMaterial(); if (material == Material.WATER) return water(); if (material == Material.LAVA) return lava(); + // Technical blocks that are invisible in the world (their textures only show held in hand). + if (INVISIBLE_MATERIALS.contains(material)) { + return new ResolvedModel(List.of(), 0, 0, 0, false, false); + } List variants = blockStateResolver.resolve(data); @@ -70,11 +75,56 @@ public final class BlockModelRegistry { return new ResolvedModel(elements, avg, 0, 0, false, true); } - // No geometry: render a flat full cube using a fallback average color. int avg = fallbackColor(lastFlat); + + // Block-entities (chests, signs, banners, beds, heads, …) use builtin/entity models with no + // geometry; their real shape is rendered separately through the entity scene. Return empty + // geometry so the ray passes through to that geometry instead of drawing a grey fallback cube. + if (isBlockEntityRendered(material)) { + return new ResolvedModel(List.of(), avg, 0, 0, false, false); + } + + // No geometry: render a flat full cube using a fallback average color. return new ResolvedModel(List.of(solidCube(avg)), avg, 0, 0, false, false); } + /** Technical blocks that render nothing in the world (barrier/light/structure void are hand-only). */ + private static final EnumSet INVISIBLE_MATERIALS = buildInvisibleMaterials(); + + private static EnumSet buildInvisibleMaterials() { + EnumSet set = EnumSet.noneOf(Material.class); + for (String n : new String[]{"BARRIER", "LIGHT", "STRUCTURE_VOID"}) { + try { set.add(Material.valueOf(n)); } catch (IllegalArgumentException ignored) { /* older/newer server */ } + } + return set; + } + + /** Block materials whose shape is drawn by the block-entity renderer, not the block-model pipeline. */ + private static final EnumSet BLOCK_ENTITY_MATERIALS = buildBlockEntityMaterials(); + + private static boolean isBlockEntityRendered(Material material) { + return BLOCK_ENTITY_MATERIALS.contains(material); + } + + private static EnumSet buildBlockEntityMaterials() { + // Matched purely by name/identity (no Material#isBlock, which needs a live registry): the set is + // only ever queried with placed block materials, so any item-only matches are harmless. + EnumSet set = EnumSet.noneOf(Material.class); + for (Material m : Material.values()) { + String n = m.name(); + boolean match = + n.endsWith("_SIGN") // signs: standing/wall/(wall_)hanging + || n.endsWith("_BANNER") // banners: standing/wall + || n.endsWith("_BED") + || n.endsWith("SHULKER_BOX") // SHULKER_BOX + _SHULKER_BOX + || ((n.endsWith("_SKULL") || n.endsWith("_HEAD")) && m != Material.PISTON_HEAD) + || m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.ENDER_CHEST + || m == Material.CONDUIT || m == Material.DECORATED_POT || m == Material.BELL; + if (match) set.add(m); + } + return set; + } + /** A full-block cube whose six faces sample a single solid color (1x1 texture). */ private Element solidCube(int color) { int[][] tex = {{color}}; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java new file mode 100644 index 0000000..f244741 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java @@ -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 textureCandidates(BlockEntityState s) { + List 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 out = new ArrayList<>(paths.size()); + for (String p : paths) out.add(ResourceLocation.parse(p)); + return out; + } + + private static void chestTextures(List 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 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"); + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java new file mode 100644 index 0000000..df70229 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java @@ -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. + * + *

{@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 patterns, // banner overlay patterns (may be empty) + List 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) {} +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java new file mode 100644 index 0000000..22ab7f3 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java @@ -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 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 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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java new file mode 100644 index 0000000..4bd1186 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java @@ -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/); 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; + }; + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java index 66108b7..e2a4f68 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java @@ -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 entities; public EntityScene(List states, CemBaker baker) { - this.entities = new ArrayList<>(states.size()); + this(states, baker, List.of(), null, List.of(), null); + } + + public EntityScene(List states, CemBaker baker, + List blockEntities, BlockEntityBaker beBaker, + List 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() { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java new file mode 100644 index 0000000..925791b --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/BlockEntityBaker.java @@ -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. + * + *

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 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 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 hidden, int texW, int texH, boolean boxUv) { + Layer(int[][] tex, Set hidden) { this(tex, hidden, 0, 0, false); } + } + + /** Some types paint different parts with different textures (pot sherds, conduit cage/heart). */ + private List 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 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 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 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 onlyPart(String keep, Set all) { + Set hidden = new java.util.HashSet<>(all); + hidden.remove(keep); + return hidden; + } + + private static final Set 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 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 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 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/}) 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; + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 25f23ea..0e5d102 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -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 hidden = HIDDEN_PARTS.getOrDefault(EntityModels.cemModel(s.typeKey()), java.util.Set.of()); - List baked = new ArrayList<>(); - bakeModel(model, tex, pre, hidden, baked); + List 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 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 hidden, List 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 hidden, CemModelLoader.CemModel model, int[][] tex, List 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 cubes = new ArrayList<>(); cubes.add(new EntityCube(from, to, faces, place)); - return finish(cubes); - } - - private RenderedEntity finish(List 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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemGeometry.java new file mode 100644 index 0000000..06dd9a7 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemGeometry.java @@ -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 bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set 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 bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set 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 bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set hidden, + int texW, int texH, boolean ignoreFaceUv) { + int nw = texW > 0 ? texW : model.texW(); + int nh = texH > 0 ? texH : model.texH(); + List 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 hidden, int texW, int texH, int[][] tex, boolean ignoreFaceUv, List 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 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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java index a29048e..56301a4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemModelLoader.java @@ -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 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"); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 9ed2dbb..66788a9 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -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 rayMap = buildRayMap(eyeLocation, superW, superH); WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger); List entities = EntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, shooter); + List blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE); + List 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 -> diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java index 0c85d02..3304a9f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/RenderJob.java @@ -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 rayMap, Vector origin, - int width, int height, SkyContext sky, List entities) { + int width, int height, SkyContext sky, List entities, + List blockEntities, List decorations) { + + /** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */ + public RenderJob(WorldSnapshot snapshot, List rayMap, Vector origin, + int width, int height, SkyContext sky, List 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 rayMap, Vector origin, + int width, int height, SkyContext sky, List entities, + List blockEntities) { + this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of()); + } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java new file mode 100644 index 0000000..9df7aa0 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -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 build(Location eye, List 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 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 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 FACE_YAW = buildFaceYaw(); + + private static Map buildFaceYaw() { + Map 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 sherds(DecoratedPot pot) { + // Order: front, left, right, back — matches the CEM decorated_pot face parts. + List 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 patterns = List.of(); + private List 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 v) { this.patterns = v; return this; } + Builder sherds(List 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); + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java new file mode 100644 index 0000000..db0f0d7 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java @@ -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 build(Location eye, List 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 nearby = eye.getWorld().getNearbyEntities(center, hx, hy, hz); + List 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; + }; + } +} diff --git a/tools/BlockEntityTestRender.java b/tools/BlockEntityTestRender.java new file mode 100644 index 0000000..e07cb2b --- /dev/null +++ b/tools/BlockEntityTestRender.java @@ -0,0 +1,240 @@ +import eu.mhsl.minecraft.pixelpics.assets.*; +import eu.mhsl.minecraft.pixelpics.render.entity.*; +import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState.*; +import eu.mhsl.minecraft.pixelpics.render.entity.cem.*; +import eu.mhsl.minecraft.pixelpics.render.render.*; +import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext; +import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot; +import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; +import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.data.BlockData; +import org.bukkit.util.Vector; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.List; +import java.util.logging.Logger; + +/** Standalone (no server) block-entity renderer: each scene framed and labelled into a contact sheet. */ +public class BlockEntityTestRender { + + static final String ROOT = "/home/elias/Dokumente/mcTestServer/plugins/PixelPics"; + static final double H_FOV_HALF = Math.toRadians(35); + static final Vector BASE = new Vector(1, 0, 0); + static final int SSAA = 3; + static final int TW = 220, TH = 240; + + static BlockEntityBaker beBaker; + static DecorationBaker decoBaker; + + static BlockEntityState be(Kind kind, int bx, int by, int bz, float yaw) { + return new BlockEntityState(kind, bx, by, bz, yaw, ChestKind.SINGLE, 0, null, "oak", + null, null, null, List.of(), List.of(), null); + } + + public static void main(String[] args) throws Exception { + Logger log = Logger.getLogger("test"); + ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow(); + AssetReader reader = new AssetReader(pack); + TextureCache textures = new TextureCache(pack); + BlockModelRegistry registry = new BlockModelRegistry(reader, textures); + BiomeTintProvider tint = new BiomeTintProvider(textures); + CemModelLoader geo = new CemModelLoader(); + geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log); + SkinCache skins = new SkinCache(); + CemBaker baker = new CemBaker(geo, textures, skins); + beBaker = new BlockEntityBaker(geo, textures, skins); + decoBaker = new DecorationBaker(textures); + DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); + + BlockData air = (BlockData) Proxy.newProxyInstance(BlockEntityTestRender.class.getClassLoader(), + new Class[]{BlockData.class}, (p, m, a) -> { + switch (m.getName()) { + case "getMaterial": return Material.AIR; + case "equals": return p == a[0]; + case "hashCode": return System.identityHashCode(p); + case "toString": return "air"; + } + Class rt = m.getReturnType(); + if (rt == boolean.class) return false; + if (rt.isPrimitive()) return 0; + return null; + }); + WorldSnapshot empty = new WorldSnapshot(Map.of(), -64, 320, air); + SkyContext sky = new SkyContext(6000, 0, 6000); + + // Scenes: label -> list of block-entity states. + LinkedHashMap> scenes = new LinkedHashMap<>(); + scenes.put("chest_S", List.of(be(Kind.CHEST, 0, 0, 0, 0))); + scenes.put("chest_N", List.of(be(Kind.CHEST, 0, 0, 0, 180))); + scenes.put("chest_E", List.of(be(Kind.CHEST, 0, 0, 0, 270))); + scenes.put("chest_W", List.of(be(Kind.CHEST, 0, 0, 0, 90))); + scenes.put("double_chest", List.of( + new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null), + new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null))); + scenes.put("sign", List.of(be(Kind.SIGN, 0, 0, 0, 0))); + scenes.put("wall_sign", List.of(be(Kind.WALL_SIGN, 0, 0, 0, 0))); + scenes.put("hanging_sign", List.of(be(Kind.HANGING_SIGN, 0, 0, 0, 0))); + scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null))); + scenes.put("banner_patterned", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFFFFFFF, null, null, null, null, null, + List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null))); + scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null))); + scenes.put("bed", List.of( + new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null), + new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null))); + scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null))); + scenes.put("conduit", List.of(be(Kind.CONDUIT, 0, 0, 0, 0))); + scenes.put("decorated_pot", List.of(be(Kind.DECORATED_POT, 0, 0, 0, 0))); + scenes.put("decorated_pot_sherds", List.of(new BlockEntityState(Kind.DECORATED_POT, 0, 0, 0, 0, null, 0, null, null, null, null, null, + List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null))); + scenes.put("bell", List.of(be(Kind.BELL, 0, 0, 0, 0))); + scenes.put("head_skeleton", List.of(head("skeleton"))); + scenes.put("head_zombie", List.of(head("zombie"))); + scenes.put("head_creeper", List.of(head("creeper"))); + scenes.put("head_dragon", List.of(head("dragon"))); + scenes.put("head_piglin", List.of(head("piglin"))); + scenes.put("wither_skull", List.of(headKind(Kind.HEAD, "wither_skeleton"))); + scenes.put("wall_head", List.of(headKind(Kind.WALL_HEAD, "skeleton"))); + + // Decoration scenes (paintings + item frames), rendered via the same scene path. + LinkedHashMap> decoScenes = new LinkedHashMap<>(); + // kebab is 1x1; place a thin quad facing south spanning the block at z=0..0.0625. + decoScenes.put("painting_kebab_S", List.of(new DecorationState(DecorationState.Kind.PAINTING, + 0, 0, 0, 1, 1, 0.0625, DecorationState.Facing.SOUTH, "kebab", null, 0, false))); + decoScenes.put("painting_kebab_N", List.of(new DecorationState(DecorationState.Kind.PAINTING, + 0, 0, 0.9375, 1, 1, 1, DecorationState.Facing.NORTH, "kebab", null, 0, false))); + decoScenes.put("painting_pool_S", List.of(new DecorationState(DecorationState.Kind.PAINTING, + 0, 0, 0, 2, 1, 0.0625, DecorationState.Facing.SOUTH, "pool", null, 0, false))); + decoScenes.put("item_frame", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, "diamond", 0, false))); + decoScenes.put("item_frame_empty", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, + 0.0625, 0.0625, 0, 0.9375, 0.9375, 0.0625, DecorationState.Facing.SOUTH, null, null, 0, false))); + decoScenes.put("item_frame_E", List.of(new DecorationState(DecorationState.Kind.ITEM_FRAME, + 0.9375, 0.0625, 0.0625, 1.0, 0.9375, 0.9375, DecorationState.Facing.EAST, null, "diamond", 0, false))); + + List cells = new ArrayList<>(); + List names = new ArrayList<>(scenes.keySet()); + for (String name : names) { + cells.add(labelCell(name, renderScene(renderer, empty, sky, scenes.get(name)))); + log.info("rendered " + name); + } + for (String name : decoScenes.keySet()) { + cells.add(labelCell(name, renderDeco(renderer, empty, sky, decoScenes.get(name)))); + names.add(name); + log.info("rendered " + name); + } + + File outDir = new File("/home/elias/Dokumente/PixelPics-BlockEntity-Renders"); + outDir.mkdirs(); + for (int i = 0; i < names.size(); i++) ImageIO.write(cells.get(i), "png", new File(outDir, names.get(i) + ".png")); + + int cols = 4, cw = cells.get(0).getWidth(), ch = cells.get(0).getHeight(); + int rows = (cells.size() + cols - 1) / cols; + BufferedImage sheet = new BufferedImage(cols * cw, rows * ch, BufferedImage.TYPE_INT_RGB); + Graphics2D g = sheet.createGraphics(); + g.setColor(new Color(40, 40, 48)); + g.fillRect(0, 0, sheet.getWidth(), sheet.getHeight()); + for (int i = 0; i < cells.size(); i++) g.drawImage(cells.get(i), (i % cols) * cw, (i / cols) * ch, null); + g.dispose(); + File f = new File(outDir, "sheet.png"); + ImageIO.write(sheet, "png", f); + System.out.println("WROTE " + f); + } + + static BlockEntityState head(String type) { return headKind(Kind.HEAD, type); } + static BlockEntityState headKind(Kind kind, String type) { + return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null); + } + + static BufferedImage renderScene(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky, + List states) { + double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; + double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; + for (BlockEntityState s : states) { + RenderedEntity re = beBaker.bake(s); + if (re == null) continue; + for (int a = 0; a < 3; a++) { min[a] = Math.min(min[a], re.aabbMin[a]); max[a] = Math.max(max[a], re.aabbMax[a]); } + } + if (min[0] > max[0]) { min = new double[]{0, 0, 0}; max = new double[]{1, 1, 1}; } + double cx = (min[0] + max[0]) / 2, cy = (min[1] + max[1]) / 2, cz = (min[2] + max[2]) / 2; + double ext = 0.5; + for (int a = 0; a < 3; a++) ext = Math.max(ext, max[a] - min[a]); + double dist = ext / (2 * Math.tan(H_FOV_HALF)) * 1.4 + 0.6; + + Vector center = new Vector(cx, cy, cz); + Vector cam = new Vector(cx - dist, cy + dist * 0.45, cz - dist * 0.35); + Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ()); + loc.setDirection(center.clone().subtract(cam)); + List rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA); + RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), states); + return renderer.execute(job); + } + + static BufferedImage renderDeco(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky, + List states) { + double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; + double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; + for (DecorationState s : states) { + RenderedEntity re = decoBaker.bake(s); + if (re == null) continue; + for (int a = 0; a < 3; a++) { min[a] = Math.min(min[a], re.aabbMin[a]); max[a] = Math.max(max[a], re.aabbMax[a]); } + } + if (min[0] > max[0]) { min = new double[]{0, 0, 0}; max = new double[]{1, 1, 1}; } + double cx = (min[0] + max[0]) / 2, cy = (min[1] + max[1]) / 2, cz = (min[2] + max[2]) / 2; + double ext = 0.5; + for (int a = 0; a < 3; a++) ext = Math.max(ext, max[a] - min[a]); + double dist = ext / (2 * Math.tan(H_FOV_HALF)) * 1.4 + 0.6; + // View from the front side (along +facing) so the picture is visible, not the back. + DecorationState.Facing f = states.get(0).facing(); + double fx = f.axis() == 0 ? f.sign() : 0, fy = f.axis() == 1 ? f.sign() : 0, fz = f.axis() == 2 ? f.sign() : 0; + Vector center = new Vector(cx, cy, cz); + Vector cam = new Vector(cx + fx * dist + 0.1, cy + fy * dist + 0.1, cz + fz * dist + 0.1); + Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ()); + loc.setDirection(center.clone().subtract(cam)); + List rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA); + RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), List.of(), states); + return renderer.execute(job); + } + + static BufferedImage labelCell(String key, BufferedImage v) { + int labelH = 20; + BufferedImage cell = new BufferedImage(v.getWidth(), v.getHeight() + labelH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = cell.createGraphics(); + g.setColor(new Color(25, 25, 30)); + g.fillRect(0, 0, cell.getWidth(), cell.getHeight()); + g.drawImage(v, 0, labelH, null); + g.setColor(Color.WHITE); + g.setFont(new Font("SansSerif", Font.BOLD, 13)); + g.drawString(key, 4, 15); + g.dispose(); + return cell; + } + + static List buildRayMap(Location eye, int width, int height) { + Vector dir = eye.getDirection(); + double angleYaw = Math.atan2(dir.getZ(), dir.getX()); + double anglePitch = Math.atan2(dir.getY(), Math.sqrt(dir.getX() * dir.getX() + dir.getZ() * dir.getZ())); + double yawHalf = H_FOV_HALF; + double pitchHalf = Math.atan(Math.tan(yawHalf) * ((double) height / width)); + Vector ll = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, -pitchHalf, angleYaw, anglePitch); + Vector ul = MathUtil.doubleYawPitchRotation(BASE, -yawHalf, pitchHalf, angleYaw, anglePitch); + Vector lr = MathUtil.doubleYawPitchRotation(BASE, yawHalf, -pitchHalf, angleYaw, anglePitch); + Vector ur = MathUtil.doubleYawPitchRotation(BASE, yawHalf, pitchHalf, angleYaw, anglePitch); + List rayMap = new ArrayList<>(width * height); + Vector leftFrac = ul.clone().subtract(ll).multiply(1.0 / (height - 1)); + Vector rightFrac = ur.clone().subtract(lr).multiply(1.0 / (height - 1)); + for (int pitch = 0; pitch < height; pitch++) { + Vector leftPitch = ul.clone().subtract(leftFrac.clone().multiply(pitch)); + Vector rightPitch = ur.clone().subtract(rightFrac.clone().multiply(pitch)); + Vector yawFrac = rightPitch.clone().subtract(leftPitch).multiply(1.0 / (width - 1)); + for (int yaw = 0; yaw < width; yaw++) rayMap.add(leftPitch.clone().add(yawFrac.clone().multiply(yaw)).normalize()); + } + return rayMap; + } +} diff --git a/tools/EntityTestRender.java b/tools/EntityTestRender.java index d0ecdd9..9049ca2 100644 --- a/tools/EntityTestRender.java +++ b/tools/EntityTestRender.java @@ -61,8 +61,10 @@ public class EntityTestRender { eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader geo = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader(); int n = geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log); log.info("Loaded " + n + " geometries"); - eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, new SkinCache()); - DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, log); + SkinCache skins = new SkinCache(); + eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins); + eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins); + DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(), new Class[]{BlockData.class}, (p, m, a) -> {