diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java index a692821..b689b25 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java @@ -1,23 +1,15 @@ package eu.mhsl.minecraft.pixelpics.render.entity; -/** One box of an entity model in model pixels: min corner, size, inflation and box-UV offset (or per-face UVs). */ +/** What {@link BoxUv} needs of an entity-model box: size (model px), box-UV offset (texels), mirror, face UVs. */ public final class ModelCube { - public final double[] origin; public final double[] size; - public final double inflate; // grows the box on all sides (overlay layers) - public final double[] uv; // box-UV offset (texels) + public final double[] uv; public final boolean mirror; // Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV. public final double[][] faceUv; - public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) { - this(origin, size, inflate, uv, mirror, null); - } - - public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) { - this.origin = origin; + public ModelCube(double[] size, double[] uv, boolean mirror, double[][] faceUv) { this.size = size; - this.inflate = inflate; this.uv = uv; this.mirror = mirror; this.faceUv = faceUv; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java index 0529f11..1d4bd6d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java @@ -23,6 +23,13 @@ public final class TextureOps { } } + /** A tinted copy of {@code src}. */ + public static int[][] tinted(int[][] src, int argb) { + int[][] out = deepCopy(src); + tint(out, argb); + return out; + } + /** Src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */ public static void overlay(int[][] dst, int[][] src) { int h = Math.min(dst.length, src.length); 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 index ff51167..16c0a49 100644 --- 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 @@ -18,49 +18,49 @@ import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; +import java.util.Locale; +import java.util.Set; /** - * Bakes a {@link BlockEntityState} into world-space cubes: the models its vanilla BlockEntityRenderer - * draws (dumped from the client, see {@link BlockEntityModels#vanillaModel}), placed by that renderer's - * own pose — {@code T(cell min corner) · pose · S(1/16) · CEM_TO_JAVA} — so position, rotation and box-UV - * match vanilla by construction (no per-type calibration). Signs add their text quads in the renderer's - * text frame (the board itself is a block model). + * Bakes a {@link BlockEntityState} into world-space cubes: the dumped models its vanilla BlockEntityRenderer draws + * ({@link BlockEntityModels#vanillaModel}) at {@code T(cell) · renderer pose}, plus sign text, contents (items, + * spawner mob), beacon beam and end portal faces. */ public final class BlockEntityBaker implements EntityBaker { - // Sign text is scaled to fill the board's writable area (W×H, font px of the renderer's text frame) so - // few-line signs render as large as possible (legibility on low-res maps), capped so a single line isn't - // blown up absurdly. Vanilla: 1 font px per text px, lines 90 (hanging 60) px wide, 10 (9) px apart. - private static final double SIGN_TEXT_W = 88, SIGN_TEXT_H = 44, SIGN_TEXT_CAP = 2.0; - private static final double HANG_TEXT_W = 58, HANG_TEXT_H = 40, HANG_TEXT_CAP = 1.87; - private static final double TEXT_THICK = - 0.2; // slab behind the text plane (text px) so the ray test never degenerates + // Deliberate deviation for legibility on 128 px maps: sign text is scaled to fill the writable area (w×h in + // text-frame px), capped so a single line isn't blown up. Vanilla: 1 font px per text px, lines 90 (hanging 60) + // px wide, 10 (9) px apart. + private record TextArea(double w, double h, double cap) {} + + private static final TextArea SIGN_TEXT = new TextArea(88, 44, 2.0); + private static final TextArea HANGING_TEXT = new TextArea(58, 40, 1.87); + // Slab behind the text plane (text px) so the ray test never degenerates. + private static final double TEXT_THICK = 0.2; private final CemModelLoader models; private final TextureCache textures; private final SkinCache skins; private final BitmapFont font; - // Composited banner flags (base + dyed pattern masks), shared by identical banners. + // Banner/shield pattern composites, shared by identical banners. private final DerivedTextureCache derived = new DerivedTextureCache(256); - // End portal/gateway face textures (per block face, the star field is not periodic) and beam textures. - private final DerivedTextureCache portals = new DerivedTextureCache(512); - // Item models (campfire/shelf/vault contents) and the mob baker (spawners); wired after construction - // because both depend on this baker themselves. Null = contents not drawn. + // End portal/gateway faces (one per block face: the star field is not periodic) and beacon beam textures. + private final DerivedTextureCache effects = new DerivedTextureCache(512); + // Contents bakers (items, spawner mob), wired after construction since both depend on this baker; null = off. private volatile ItemGeometry items; private volatile CemBaker entities; - /** Item geometry for campfire/shelf/vault contents. */ public void setItemGeometry(ItemGeometry items) { this.items = items; } - /** Mob baker for the spawner's display entity. */ public void setEntityBaker(CemBaker entities) { this.entities = entities; } - /** The sign/text font (shared with text-display rendering). */ + /** The sign font, shared with text displays. */ public BitmapFont font() { return font; } @@ -84,35 +84,38 @@ public final class BlockEntityBaker implements EntityBaker { return cubes.isEmpty() ? null : RenderedEntity.of(cubes); } - /** - * The block-entity's models WITHOUT the block pose, in Java model space (blocks) mapped by - * {@code toWorld}: what the special item renderers and the worn-skull layer submit (the item model's - * transformation / the head frame place it). - */ + /** The models WITHOUT the block pose, mapped by {@code toWorld} (special item renderers, worn skulls). */ public void bakeModel(BlockEntityState s, Affine toWorld, List out) { BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s); if (vm != null) bakeParts(s, vm, toWorld, out); } private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List out) { - Affine pre = pose.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); + Affine pre = CemGeometry.modelSpace(pose); for (BlockEntityModels.Part part : vm.parts()) { CemModelLoader.CemModel model = models.vanilla(part.model()); int[][] tex = model == null ? null : texture(s, part); - if (tex == null) continue; - for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, part.hidden())) { - out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world())); - } + if (tex != null) CemGeometry.bakeCubes(model, tex, pre, part.hidden(), out); } } // --- contents: items, spawner mob, beacon beam, end portal --- private void addContents(BlockEntityState s, Affine cell, List cubes) { + ItemGeometry ig = items; switch (s.kind()) { - case CAMPFIRE -> addCampfireItems(s, cell, cubes); - case SHELF -> addShelfItems(s, cell, cubes); - case VAULT -> addVaultItem(s, cell, cubes); + case CAMPFIRE -> { + if (ig != null) addCampfireItems(ig, s, cell, cubes); + } + case SHELF -> { + if (ig != null) addShelfItems(ig, s, cell, cubes); + } + case VAULT -> { + if (ig != null && slot(s, 0) != null) { + // VaultRenderer: T(.5, .4, .5)·rotY(spin 0), ground context. + ig.bake(slot(s, 0), "ground", false, cell.mul(Affine.translation(0.5, 0.4, 0.5)), cubes); + } + } case SPAWNER -> addSpawnerMob(s, cell, cubes); case BEACON -> addBeam(s, cell, cubes); case END_PORTAL, END_GATEWAY -> addPortal(s, cell, cubes); @@ -129,9 +132,7 @@ public final class BlockEntityBaker implements EntityBaker { * T(-0.3125, -0.3125, 0)·S(0.375)}, {@code dir = from2DDataValue((i + facing.get2DDataValue()) % 4)}, in * the {@code fixed} display context. */ - private void addCampfireItems(BlockEntityState s, Affine cell, List cubes) { - ItemGeometry ig = items; - if (ig == null) return; + private static void addCampfireItems(ItemGeometry ig, BlockEntityState s, Affine cell, List cubes) { int facing2d = Math.floorMod(Math.round(s.facingDeg() / 90), 4); // S0 W1 N2 E3 = yaw / 90 for (int i = 0; i < 4; i++) { ItemSpec item = slot(s, i); @@ -151,9 +152,7 @@ public final class BlockEntityBaker implements EntityBaker { * T((i-1)·0.3125, 0, -0.25)·S(0.25)·T(0, -minY - (maxY-minY)/2, 0)} — the item's {@code on_shelf} model * bounding box vertically centred. */ - private void addShelfItems(BlockEntityState s, Affine cell, List cubes) { - ItemGeometry ig = items; - if (ig == null) return; + private static void addShelfItems(ItemGeometry ig, BlockEntityState s, Affine cell, List cubes) { for (int i = 0; i < 3; i++) { ItemSpec item = slot(s, i); if (item == null) continue; @@ -169,18 +168,10 @@ public final class BlockEntityBaker implements EntityBaker { } } - /** VaultRenderer: the display item at {@code T(.5, .4, .5)·rotY(spin)} (spin 0), {@code ground} context. */ - private void addVaultItem(BlockEntityState s, Affine cell, List cubes) { - ItemGeometry ig = items; - ItemSpec item = slot(s, 0); - if (ig == null || item == null) return; - ig.bake(item, "ground", false, cell.mul(Affine.translation(0.5, 0.4, 0.5)), cubes); - } - /** * SpawnerRenderer.submitEntityInSpawner: {@code T(.5,.4,.5)·rotY(spin)·T(0,-.2,0)·rotX(-30°)·S(scale)} - * (spin 0, scale 0.53125 / max(width, height) when that exceeds 1), then the mob as the entity - * dispatcher places it at the origin (baked at its own position, moved back by -pos). + * (spin 0, scale 0.53125 / max(width, height) when that exceeds 1), the mob at the origin (baked at its own + * position, moved back by -pos). */ private void addSpawnerMob(BlockEntityState s, Affine cell, List cubes) { CemBaker baker = entities; @@ -203,15 +194,15 @@ public final class BlockEntityBaker implements EntityBaker { // BeaconRenderer: sections up to MAX_RENDER_Y (the last one), solid core radius 0.2 rotated by // animationTime·2.25 − 45° (time 0: an axis-aligned square of half-width 0.2/√2), texture v 2.5× per block; - // glow radius 0.25 axis-aligned, alpha 32, v 1× per block. Both unlit (full-bright), no caps. + // glow radius 0.25 axis-aligned, alpha 32, v 1× per block. Both unlit, no caps. private static final int BEAM_MAX = 2048; private static final int BEAM_PIECE = 32; // blocks per cube; textures tiled to cover it + private static final Direction[] SIDES = {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}; private void addBeam(BlockEntityState s, Affine cell, List cubes) { BlockEntityState.Beam beam = s.beam(); if (beam == null || beam.sections().isEmpty()) return; - int[][] tex = textures.get(ResourceLocation.parse("entity/beacon/beacon_beam")) - .orElse(null); + int[][] tex = texture("entity/beacon/beacon_beam"); if (tex == null) return; double core = 0.2 * beam.radiusScale() / Math.sqrt(2), glow = 0.25 * beam.radiusScale(); int start = 0; @@ -235,9 +226,7 @@ public final class BlockEntityBaker implements EntityBaker { private static EntityCube beamCube(int[][] tex, double r, int len, double v, Affine at) { Face side = new Face(tex, 0, v, 1, 0, 0, -1); Face[] faces = new Face[6]; - for (Direction d : new Direction[] {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}) { - faces[d.ordinal()] = side; - } + for (Direction d : SIDES) faces[d.ordinal()] = side; return new EntityCube(new double[] {-r, 0, -r}, new double[] {r, len, r}, faces, at, false, true); } @@ -246,7 +235,7 @@ public final class BlockEntityBaker implements EntityBaker { * with the section colour: the core opaque (BEACON_BEAM_OPAQUE), the glow at alpha 32/255. */ private int[][] beamTexture(int[][] tex, int argb, boolean glowLayer) { - return portals.get(DerivedTextureCache.key("beam", tex, argb, glowLayer), () -> { + return effects.get(DerivedTextureCache.key("beam", tex, argb, glowLayer), () -> { int reps = glowLayer ? BEAM_PIECE : BEAM_PIECE * 5 / 2; int h = tex.length, w = tex[0].length; int[][] out = new int[h * reps][w]; @@ -274,43 +263,57 @@ public final class BlockEntityBaker implements EntityBaker { }; private static final double PORTAL_SPAN = 16; private static final int PORTAL_RES = 64; // texels per block face edge + // end_portal_layer constants per layer i (layer = i + 1): scale, rotation and x shift 17/layer. + private static final double[] LAYER_SCALE = new double[16], LAYER_COS = new double[16], LAYER_SIN = new double[16]; + private static final double[] LAYER_SHIFT = new double[16]; + + static { + for (int i = 0; i < 16; i++) { + double layer = i + 1; + LAYER_SCALE[i] = (4.5 - layer / 4.0) * 2.0; + double ang = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0); + LAYER_COS[i] = Math.cos(ang); + LAYER_SIN[i] = Math.sin(ang); + LAYER_SHIFT[i] = 17.0 / layer; + } + } /** * TheEndPortalRenderer: the top and bottom face of {@code T(0,.375,0)·S(1,.375,1)} (y 0.375–0.75); * TheEndGatewayRenderer: the full cube's unoccluded faces. Unlit like the end_portal pipeline. */ private void addPortal(BlockEntityState s, Affine cell, List cubes) { - int[][] sky = - textures.get(ResourceLocation.parse("environment/end_sky")).orElse(null); - int[][] stars = textures.get(ResourceLocation.parse("entity/end_portal/end_portal")) - .orElse(null); + int[][] sky = texture("environment/end_sky"); + int[][] stars = texture("entity/end_portal/end_portal"); if (sky == null || stars == null) return; boolean gateway = s.kind() == BlockEntityState.Kind.END_GATEWAY; double y0 = gateway ? 0 : 0.375, y1 = gateway ? 1 : 0.75; + int layers = gateway ? 16 : 15; + Set shown = gateway ? gatewayFaces(s.direction()) : EnumSet.of(Direction.UP, Direction.DOWN); Face[] faces = new Face[6]; - for (Direction d : Direction.values()) { - boolean show = gateway - ? s.direction() == null - || ("+" + s.direction() + "+") - .contains("+" + d.name().toLowerCase(java.util.Locale.ROOT) + "+") - : d == Direction.UP || d == Direction.DOWN; - if (!show) continue; - int layers = gateway ? 16 : 15; - faces[d.ordinal()] = new Face( - portals.get( - DerivedTextureCache.key("portal", sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1), - () -> portalFace(sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1)), - 0, - 0, - 1, - 1, - 0, - -1); + for (Direction d : shown) { + int[][] tex = effects.get( + DerivedTextureCache.key("portal", sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1), + () -> portalFace(sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1)); + faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1); } cubes.add(new EntityCube(new double[] {0, y0, 0}, new double[] {1, y1, 1}, faces, cell, false, true)); } - /** One face texture: each texel's world point on the face, projected onto the face plane, shaded. */ + /** The "+"-joined face names of a gateway state; null = all faces. */ + private static Set gatewayFaces(String joined) { + if (joined == null) return EnumSet.allOf(Direction.class); + Set out = EnumSet.noneOf(Direction.class); + for (Direction d : Direction.values()) { + if (("+" + joined + "+").contains("+" + d.name().toLowerCase(Locale.ROOT) + "+")) out.add(d); + } + return out; + } + + /** + * One face texture: each texel's world point on the face (inverting ElementIntersector.faceS/faceT), projected + * onto the face plane. + */ private static int[][] portalFace( int[][] sky, int[][] stars, int layers, Direction d, int bx, int by, int bz, double y0, double y1) { int n = PORTAL_RES; @@ -318,51 +321,31 @@ public final class BlockEntityBaker implements EntityBaker { for (int j = 0; j < n; j++) { for (int i = 0; i < n; i++) { double fs = (i + 0.5) / n, ft = (j + 0.5) / n; - // Invert ElementIntersector.faceS/faceT to the face point (local 0..1 per axis). - double fx = 0, fy = 0, fz = 0; - switch (d) { - case UP -> { - fx = fs; - fz = ft; - fy = 1; - } - case DOWN -> { - fx = fs; - fz = 1 - ft; - } - case SOUTH -> { - fx = fs; - fy = 1 - ft; - fz = 1; - } - case NORTH -> { - fx = 1 - fs; - fy = 1 - ft; - } - case WEST -> { - fz = fs; - fy = 1 - ft; - } - case EAST -> { - fz = 1 - fs; - fy = 1 - ft; - fx = 1; - } - } - double wx = bx + fx, wy = by + y0 + fy * (y1 - y0), wz = bz + fz; double a, b; switch (d) { - case UP, DOWN -> { - a = wx; - b = wz; + case UP -> { + a = bx + fs; + b = bz + ft; } - case NORTH, SOUTH -> { - a = wx; - b = -wy; + case DOWN -> { + a = bx + fs; + b = bz + (1 - ft); + } + case SOUTH -> { + a = bx + fs; + b = -(by + y0 + (1 - ft) * (y1 - y0)); + } + case NORTH -> { + a = bx + (1 - fs); + b = -(by + y0 + (1 - ft) * (y1 - y0)); + } + case WEST -> { + a = bz + fs; + b = -(by + y0 + (1 - ft) * (y1 - y0)); } default -> { - a = wz; - b = -wy; + a = bz + (1 - fs); + b = -(by + y0 + (1 - ft) * (y1 - y0)); } } out[j][i] = portalColor(sky, stars, layers, a / PORTAL_SPAN, b / PORTAL_SPAN); @@ -378,13 +361,10 @@ public final class BlockEntityBaker implements EntityBaker { double g = ColorUtil.green(skyTexel) / 255.0 * c0[1]; double b = ColorUtil.blue(skyTexel) / 255.0 * c0[2]; for (int i = 0; i < layers; i++) { - double layer = i + 1; // end_portal_layer: (scale·rotate) then translate (17/layer, GameTime term) then ×0.5 + 0.25. - double sc = (4.5 - layer / 4.0) * 2.0; - double ang = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0); - double cos = Math.cos(ang), sin = Math.sin(ang); + double sc = LAYER_SCALE[i], cos = LAYER_COS[i], sin = LAYER_SIN[i]; double x = sc * (u * cos - v * sin), y = sc * (u * sin + v * cos); - x = (x + 17.0 / layer) * 0.5 + 0.25; + x = (x + LAYER_SHIFT[i]) * 0.5 + 0.25; y = y * 0.5 + 0.25; int t = sampleRepeat(stars, x, y); double[] c = PORTAL_COLORS[i]; @@ -392,9 +372,11 @@ public final class BlockEntityBaker implements EntityBaker { g += ColorUtil.green(t) / 255.0 * c[1]; b += ColorUtil.blue(t) / 255.0 * c[2]; } - return ColorUtil.argb( - 255, (int) Math.min(255, Math.round(r * 255)), (int) Math.min(255, Math.round(g * 255)), (int) - Math.min(255, Math.round(b * 255))); + return ColorUtil.argb(255, channel(r), channel(g), channel(b)); + } + + private static int channel(double v) { + return (int) Math.min(255, Math.round(v * 255)); } private static int sampleRepeat(int[][] tex, double u, double v) { @@ -405,33 +387,30 @@ public final class BlockEntityBaker implements EntityBaker { // --- textures --- + private int[][] texture(String path) { + return textures.get(ResourceLocation.parse(path)).orElse(null); + } + private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) { if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) { int[][] skin = skins.get(s.skinUrl()).orElse(null); if (skin != null) return skin; } for (String path : part.textures()) { - int[][] t = textures.get(ResourceLocation.parse(path)).orElse(null); + int[][] t = texture(path); if (t == null) continue; - return part.paint() == BlockEntityModels.Paint.BANNER_FLAG ? bannerFlag(t, s) : t; + return part.paint() == BlockEntityModels.Paint.BANNER_FLAG + ? patterned(t, "entity/banner/", s.baseColorArgb(), s.patterns()) + : t; } return null; } /** - * BannerRenderer flag: {@code banner_base}, then the {@code base} mask dyed with the base colour, then - * each pattern mask ({@code entity/banner/}) dyed with its colour, composited into one texture - * (one geometry, no coplanar layers). - */ - private int[][] bannerFlag(int[][] base, BlockEntityState s) { - return patterned(base, "entity/banner/", s.baseColorArgb(), s.patterns()); - } - - /** - * BannerRenderer.submitPatterns composited into one texture: {@code base} (banner_base / shield_base), the - * {@code base} mask dyed with {@code baseColor} (0 = none), then the first 16 pattern masks - * ({@code }) dyed with their colours. {@code dir} = {@code entity/banner/} or {@code entity/shield/} - * (shields, ShieldSpecialRenderer). Cached per input. + * BannerRenderer.submitPatterns composited into one texture (one geometry, no coplanar layers): {@code base} + * (banner_base / shield_base), the {@code base} mask dyed with {@code baseColor} (0 = none), then the + * first 16 pattern masks ({@code }) dyed. {@code dir} = {@code entity/banner/} or + * {@code entity/shield/} (ShieldSpecialRenderer). Cached. */ public int[][] patterned(int[][] base, String dir, int baseColor, List patterns) { List pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size()))); @@ -451,46 +430,39 @@ public final class BlockEntityBaker implements EntityBaker { } private void overlayMask(int[][] out, String path, int argb) { - int[][] mask = textures.get(ResourceLocation.parse(path)).orElse(null); + int[][] mask = texture(path); if (mask == null || mask.length != out.length || mask[0].length != out[0].length) return; - int[][] dyed = TextureOps.deepCopy(mask); - TextureOps.tint(dyed, argb); - TextureOps.overlay(out, dyed); + TextureOps.overlay(out, TextureOps.tinted(mask, argb)); } // --- sign text --- /** - * Front and back text as flat quads in the sign renderer's text frame - * ({@link BlockEntityModels#signTextFrame}): the text block is centred on the frame origin and faces - * +Z (the reader). The quad's +Z (SOUTH) face is unflipped: viewed from +Z the intersector reads it - * left to right ({@code s = fx}). + * Front and back text as quads centred in the sign renderer's text frame ({@link BlockEntityModels#signTextFrame}) + * facing +Z, the reader; the +Z (SOUTH) face reads left to right ({@code s = fx}). */ private void addSignText(BlockEntityState s, Affine cell, List cubes) { - if (font == null || font.isEmpty()) return; + if (font == null) return; BlockEntityState.Kind k = s.kind(); boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN; if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return; - addSide(s, s.frontText(), true, hanging, cell, cubes); - addSide(s, s.backText(), false, hanging, cell, cubes); + TextArea area = hanging ? HANGING_TEXT : SIGN_TEXT; + addSide(s, s.frontText(), true, area, cell, cubes); + addSide(s, s.backText(), false, area, cell, cubes); } private void addSide( BlockEntityState s, BlockEntityState.SignText t, boolean front, - boolean hanging, + TextArea area, Affine cell, List cubes) { if (t == null) return; int[][] bmp = SignTextRasterizer.rasterize( trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing()); if (bmp == null) return; - double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W; - double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H; - double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP; - // Scale so the text block fills the writable area, whichever dimension binds, capped. - double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length)); + double fpm = Math.min(area.cap, Math.min(area.w / bmp[0].length, area.h / bmp.length)); double w = bmp[0].length * fpm, h = bmp.length * fpm; double[] px = new double[1]; Affine frame = BlockEntityModels.signTextFrame(s, front, px); 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 bb9e3e5..149e565 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 @@ -17,31 +17,28 @@ import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube; import eu.mhsl.minecraft.pixelpics.render.entity.RenderedEntity; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; /** - * Bakes an {@link EntityState} into world-space cubes. - * - *

Primary source: the VANILLA models dumped from the client jar ({@code tools/VanillaModelDump.java}, - * "absolute" models). They are posed by vanilla's own {@code setupAnim} (idle, adult, peaceful), carry - * vanilla part visibility, real baby models, per-variant models, and the renderer's layer models - * (outer layers, saddles, body equipment, humanoid armor, elytra). Model choice is generic and - * name-based ({@code }, {@code /baby}, {@code /<…variant…>}, {@code /.…}); - * which layers vanilla draws by default, who wears humanoid armor, state pose flags (sitting, held items, - * armor-stand arms, chests) and data-driven variant assets ({@code asset_id}, model kind) come from the - * dump too, so mobs of a newer dump work without code changes. Vanilla models are placed with their feet at the - * entity position (model y=0), exactly like vanilla — floating mobs keep their height. - * - *

Fallback: the CEM template set (rest geometry only, ground-snapped, no layers) for a type the dump - * lacks. + * Bakes an {@link EntityState} into world-space cubes from the vanilla models dumped from the client jar + * ({@code tools/VanillaModelDump.java}): posed by vanilla's {@code setupAnim}, with baby/variant/layer models, + * default layers, armor wearers, pose variants and variant assets all taken from the dump (no per-type lists). + * Placed like vanilla, feet at the entity position. Types missing from the dump fall back to the CEM template + * set (rest pose, ground-snapped, no layers). */ public final class CemBaker implements EntityBaker { - // LivingEntityRenderer at yaw 0: rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0) — used when the dump has no - // captured transform for a type. + // LivingEntityRenderer at yaw 0 (rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0)) for types without a dumped + // transform. private static final Affine DEFAULT_LIVING_TRANSFORM = new Affine(new double[] {1, 0, 0, 0, -1, 0, 0, 0, -1}, new double[] {0, 1.501, 0}); @@ -49,12 +46,11 @@ public final class CemBaker implements EntityBaker { private final TextureCache textures; private final SkinCache skins; private final Map> subModelCache = new ConcurrentHashMap<>(); - // Tinted / composited per-entity textures (wool, villager overlays, armor, collars, …), shared by identical mobs. + // Tinted / composited textures (wool, villager overlays, armor, collars, …), shared by identical mobs. private final DerivedTextureCache derived = new DerivedTextureCache(512); private volatile ItemGeometry items; // held/head items; null = not drawn - /** Enables held-item and head-item rendering. */ public void setItemGeometry(ItemGeometry items) { this.items = items; } @@ -69,26 +65,114 @@ public final class CemBaker implements EntityBaker { this.skins = skins; } - /** - * The chosen body model: the model, its name, the base type its layers live under, and whether it IS a - * baby model ({@code model.absolute()} tells vanilla dump from legacy CEM template). - */ + /** The body model (vanilla dump if {@code model.absolute()}), its name, the type its layers live under. */ private record Choice(CemModelLoader.CemModel model, String name, String base, boolean babyModel) {} + /** {@code pre} maps model px to entity space; {@code place} puts it in the world (null = ground snap later). */ + private record Placement(Affine t0, Affine pre, Affine place) {} + @Override public RenderedEntity bake(EntityState s) { Choice ch = chooseModel(s); CemModelLoader.CemModel model = ch == null ? null : ch.model; boolean vanilla = model != null && model.absolute(); String base = ch == null ? s.typeKey() : ch.base; + boolean babyModel = ch != null && ch.babyModel; + CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey()); + if (info == null) info = models.entityInfo(base); - int[][] tex = resolveTexture(s, ch); - // Same-UV overlays are composited straight into the base texture (no extra geometry -> no Z-fighting). + int[][] tex = bodyTexture(s, resolveTexture(s, info, babyModel), base, babyModel); + boolean invisible = s.invisible(); + if (model == null) return invisible ? null : fallbackBox(s, tex); + if (!invisible && tex == null) return fallbackBox(s, tex); + + double extra = (s.baby() && !babyModel ? 0.5 : 1.0) * s.sizeScale(); // no baby model -> 0.5x adult + SelectedPose sel = vanilla ? selectPose(s, info, ch) : SelectedPose.NONE; + Placement at = vanilla + ? vanillaPlacement(s, info, sel.transform(), extra) + : new Placement(null, Affine.scale(extra / 16.0), null); + + // Body: baked even when invisible (not drawn, but it yields the part frames for items). + Pose pose = new Pose(at.pre, vanilla ? withBindings(sel.parts(), s, info) : Map.of()); + Map frames = vanilla && items != null && holdsItem(s) ? new HashMap<>() : null; + List body = tex != null + ? CemGeometry.bakePosed(model, renderTexture(model, tex), at.pre, pose.parts, frames) + : List.of(); + + Out out = new Out(); + List itemCubes = new ArrayList<>(); + if (vanilla) { + if (!invisible) { + (unshaded(model) ? out.unshaded : out.lit).addAll(body); + addVanillaLayers(s, ch, info, pose, tex, out); + } + // Drawn even when invisible (vanilla "floating armor"). + if (s.equipment() != null && info != null && info.humanoidArmor()) addArmorLayers(s, ch, pose, out); + if (!invisible) addShoulderParrots(s, ch, at.t0, out); + addEmissiveLayers(s, ch, info, pose, out); + if (frames != null) addItems(s, frames, isIllager(info), itemCubes); + } else if (!invisible) { + out.lit.addAll(body); + } + if (out.lit.isEmpty() && out.unshaded.isEmpty() && out.glow.isEmpty() && itemCubes.isEmpty()) { + return invisible ? null : fallbackBox(s, tex); + } + + Affine place = at.place != null ? at.place : groundSnap(s, out.lit); + List cubes = new ArrayList<>(out.lit.size()); + for (CemGeometry.Baked b : out.lit) + cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); + for (CemGeometry.Baked b : out.unshaded) { + cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), false, true)); + } + for (CemGeometry.Baked b : out.glow) { + cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), true)); + } + for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld))); + return RenderedEntity.of(cubes); + } + + private static boolean holdsItem(EntityState s) { + return s.rightHand() != null || s.leftHand() != null || s.headItem() != null || s.mouthItem() != null; + } + + /** + * The renderer's captured pose (or the pose variant's own, e.g. sleeping in a bed, swimming) with yaw and pitch + * applied the way that renderer applies them. Projectiles (checked against ArrowRenderer/ThrownTridentRenderer + * 26.3): {@code rotY(yaw)·rotAxis(-X, pitch)·T0} == vanilla {@code rotY(yRot-90)·rotZ(xRot[+90])} with the raw + * projectile rotation. + */ + private static Placement vanillaPlacement( + EntityState s, CemModelLoader.EntityInfo info, CemModelLoader.PoseTransform pt, double extra) { + boolean captured = info != null && info.transform() != null; + double[] tr = pt != null && pt.transform() != null ? pt.transform() : captured ? info.transform() : null; + Affine t0 = tr != null ? Affine.fromColumnMajor(tr) : DEFAULT_LIVING_TRANSFORM; + Affine pre = CemGeometry.modelSpace(t0); + int yawSign = pt != null ? pt.yawSign() : captured ? info.yawSign() : -1; + double[] a = pt != null ? pt.pitchAxis() : info != null ? info.pitchAxis() : null; + Affine place = Affine.translation(s.x(), s.y(), s.z()) + .mul(Affine.scale(extra)) + .mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw()))); + if (a != null && s.pitch() != 0) place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch()))); + return new Placement(t0, pre, place); + } + + /** Legacy CEM models: the lowest point snapped to the ground (their rest geometry has no reliable origin). */ + private static Affine groundSnap(EntityState s, List ref) { + double minY = Double.MAX_VALUE; + for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY()); + return Affine.translation(s.x(), s.y(), s.z()) + .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) + .mul(Affine.translation(0, -minY, 0)); + } + + /** Same-UV overlays composited into the body texture (no extra geometry, no coplanar layers). */ + private int[][] bodyTexture(EntityState s, int[][] tex, String base, boolean babyModel) { if (base.equals("villager") || base.equals("zombie_villager")) { - tex = compositeVillager(s, tex, base, ch != null && ch.babyModel); + tex = compositeVillager(s, tex, base, babyModel); } else if (s.markings() != null && tex != null) { - String m = "entity/horse/horse_markings_" + s.markings(); // horse coat markings - String path = ch != null && ch.babyModel ? m + "_baby" : m; + String m = "entity/horse/horse_markings_" + s.markings(); + String path = babyModel ? m + "_baby" : m; int[][] src = tex; tex = derived.get(DerivedTextureCache.key("markings", src, path), () -> { int[][] out = TextureOps.deepCopy(src); @@ -97,135 +181,40 @@ public final class CemBaker implements EntityBaker { }); } if (s.collar() != 0 && tex != null && layerModels(base).stream().noneMatch(n -> n.contains("CollarLayer."))) { - // Collar drawn on the body model itself (WolfCollarLayer: parent model, same UV) -> composite it. - tex = withCollar(tex, base, ch != null && ch.babyModel, s.collar()); + // WolfCollarLayer draws the parent model (same UV). + tex = withCollar(tex, base, babyModel, s.collar()); } - if (s.typeKey().equals("tropical_fish") && tex != null && s.tint() != 0) { - tex = tinted(tex, s.tint()); // white base body tinted with the body colour - } - - boolean invisible = s.invisible(); - if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex); - - CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey()); - if (info == null) info = models.entityInfo(base); - boolean halfBaby = s.baby() && (ch == null || !ch.babyModel); // no dedicated baby model -> 0.5x adult - double extra = (halfBaby ? 0.5 : 1.0) * s.sizeScale(); - SelectedPose sel = vanilla ? selectPose(s, info, ch) : SelectedPose.NONE; - Affine t0 = null; - Affine pre; - Affine place; - if (vanilla) { - // Vanilla placement: the renderer's own captured pose (incl. its scale / offsets), yaw and pitch - // applied the way that renderer applies them — the pose's own when it changes them (sleeping in a - // bed, swimming, fish in water, …). CEM px -> Java model units: F·T(0,-24,0)/16. - CemModelLoader.PoseTransform pt = sel.transform(); - boolean captured = info != null && info.transform() != null; - double[] tr = pt != null && pt.transform() != null ? pt.transform() : captured ? info.transform() : null; - t0 = tr != null ? Affine.fromColumnMajor(tr) : DEFAULT_LIVING_TRANSFORM; - pre = t0.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); - int yawSign = pt != null ? pt.yawSign() : captured ? info.yawSign() : -1; - double[] pitchAxis = pt != null ? pt.pitchAxis() : info != null ? info.pitchAxis() : null; - place = Affine.translation(s.x(), s.y(), s.z()) - .mul(Affine.scale(extra)) - .mul(Affine.rotY(yawSign * Math.toRadians(s.bodyYaw()))); - // Projectiles (verified against ArrowRenderer/ThrownTridentRenderer 26.3): T0 = rotY(-90)[·rotZ(90)], - // pitchAxis = -X, so rotY(yaw)·rotAxis(-X, pitch)·T0 == vanilla rotY(yRot-90)·rotZ(xRot[+90]) with the - // raw projectile rotation (yaw atan2(dx,dz), pitch positive up); origin = entity position, no offset. - // Mob poses: the pitch rotation follows the body yaw directly (swimming/gliding players, pouncing fox). - if (pitchAxis != null && s.pitch() != 0) { - double[] a = pitchAxis; - place = place.mul(Affine.rotAxis(a[0], a[1], a[2], Math.toRadians(s.pitch()))); - } - } else { - pre = Affine.scale(extra / 16.0); - place = null; // legacy: computed after baking (ground snap) - } - - // Body: baked even when invisible — not drawn, but it yields the part frames (and legacy ground snap). - Pose pose = new Pose(pre, vanilla ? withBindings(sel.parts(), s, info) : Map.of()); - Map frames = new java.util.HashMap<>(); - List body = (model != null && tex != null) - ? CemGeometry.bakePosed(model, renderTexture(model, tex), pre, pose.parts, frames) - : List.of(); - - Out out = new Out(); - if (!invisible) { - (vanilla && unshaded(model) ? out.unshaded : out.lit).addAll(body); - if (vanilla) addVanillaLayers(s, ch, info, pose, tex, out); - } - // Humanoid armor / elytra (renderers with a HumanoidArmorLayer): drawn even when invisible (vanilla - // "floating armor"). - if (vanilla && s.equipment() != null && info != null && info.humanoidArmor()) { - addArmorLayers(s, ch, pose, out); - } - if (vanilla && !invisible) addShoulderParrots(s, ch, t0, out); - if (vanilla) addEmissiveLayers(s, ch, info, pose, out); - List baked = out.lit; - List glow = out.glow; - List itemCubes = new ArrayList<>(); - if (vanilla && items != null) addItems(s, frames, isIllager(info), itemCubes); - if (baked.isEmpty() && out.unshaded.isEmpty() && glow.isEmpty() && itemCubes.isEmpty()) { - return invisible ? null : fallbackBox(s, tex); - } - - // Legacy CEM models: snap the lowest point to the ground (their rest geometry has no reliable origin). - if (place == null) { - List ref = (invisible && !body.isEmpty()) ? body : baked; - double minY = Double.MAX_VALUE; - for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY()); - 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 (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); - for (CemGeometry.Baked b : out.unshaded) { - cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), false, true)); - } - for (CemGeometry.Baked b : glow) { - cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), true)); - } - for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld))); - return RenderedEntity.of(cubes); + if (s.typeKey().equals("tropical_fish") && tex != null) tex = tinted(tex, s.tint()); + return tex; } // --- poses & items ------------------------------------------------------------------------------- - /** The model→pre-space transform plus the pose overrides shared by every vanilla model of one entity. */ + /** Pre-space transform and pose overrides shared by every vanilla model of one entity. */ private record Pose(Affine pre, Map parts) { List bake(CemModelLoader.CemModel m, int[][] tex) { return CemGeometry.bakePosed(m, tex, pre, parts, null); } } - /** - * Baked boxes of one entity by how they are drawn: shaded (normal), unshaded (render types without cardinal - * lighting: eyes, breeze wind) and additive (energy swirl). - */ + /** Baked boxes by how they are drawn: shaded, unshaded (NO_CARDINAL_LIGHTING) and additive (energy swirl). */ private static final class Out { final List lit = new ArrayList<>(); final List unshaded = new ArrayList<>(); final List glow = new ArrayList<>(); } - /** - * Bakes a vanilla model the way its render type draws it: texels processed by {@link #renderTexture}, routed to - * the unshaded boxes when the pipeline has no cardinal lighting. - */ + /** A vanilla model drawn the way its render type draws it. */ private void add(Out out, Pose pose, CemModelLoader.CemModel m, int[][] tex) { (unshaded(m) ? out.unshaded : out.lit).addAll(pose.bake(m, renderTexture(m, tex))); } - // Overlay layers that cover surfaces of the body (EyesLayer draws the parent model itself; the warden / copper - // golem / creaking emissive layers and the sheep undercoat draw the body mesh or subsets of it, no inflation). - // Vanilla draws them afterwards and the depth test passes on equal depth, i.e. on top of the coplanar body; here - // their boxes grow by this much (model px) so they lie just in front of it — well above the tracer's layer - // epsilon, far below a visible size (on coplanar ties the earlier-baked body would win). + // Overlay layers drawn on the body mesh itself (eyes, warden/copper golem/creaking emissive, sheep undercoat): + // vanilla draws them later and the depth test passes on equal depth. Here their boxes grow by this much (model + // px) to lie just in front (on coplanar ties the earlier-baked body would win); well above the tracer's layer + // epsilon, far below a visible size. private static final double OVERLAY_INFLATE = 1 / 256.0; - /** {@code baked} boxes grown by {@link #OVERLAY_INFLATE}, added to {@code target}. */ private static void addOverlay(List target, List baked) { for (CemGeometry.Baked b : baked) { double[] from = b.from().clone(), to = b.to().clone(); @@ -244,11 +233,10 @@ public final class CemBaker implements EntityBaker { /** * A texture as the model's vanilla render type sees it: texels below the shader's ALPHA_CUTOUT (0.1 → alpha - * < 26) are discarded; cutout and solid types write the rest opaque (partial alpha in e.g. llama or - * tropical-fish pattern textures is NOT blended in vanilla), solid types draw even fully transparent texels; - * only blending types (entity_translucent: slime skin, vex, allay, player skins, …) keep the alpha for the - * tracer to blend. Unknown render type (legacy models, old dumps) or additive: unchanged. Cached; returns - * {@code tex} itself when nothing changes. + * < 26) are discarded; cutout and solid types write the rest opaque (partial alpha of e.g. llama or + * tropical-fish patterns is NOT blended in vanilla), solid types even fully transparent texels; only blending + * types (entity_translucent: slime, vex, allay, player skins, …) keep the alpha. Unknown or additive render + * type: unchanged. Cached; {@code tex} itself when nothing changes. */ private int[][] renderTexture(CemModelLoader.CemModel m, int[][] tex) { return renderTexture(m == null ? null : models.renderType(m.renderType()), tex); @@ -274,35 +262,25 @@ public final class CemBaker implements EntityBaker { }); } - /** A chosen pose variant: its name, part overrides and (when it changes it) the renderer transform. */ - private record SelectedPose( - String name, Map parts, CemModelLoader.PoseTransform transform) { - static final SelectedPose NONE = new SelectedPose(null, Map.of(), null); + /** A chosen pose variant: part overrides and (when it changes it) the renderer transform. */ + private record SelectedPose(Map parts, CemModelLoader.PoseTransform transform) { + static final SelectedPose NONE = new SelectedPose(Map.of(), null); } - // Toggle flags that switch parts on/off or seat the entity; kept when held-item flags are dropped. - private static final java.util.Set PART_TOGGLES = java.util.Set.of("passenger", "arms", "chest"); - /** - * Picks the vanilla pose variant for the entity's state flags — the render-state fields vanilla's - * {@code setupAnim} / renderer read, dumped per type and per body model (baby/small models have their own - * pivots): sitting as a passenger ({@code passenger}), holding items ({@code item_r}/{@code item_l}), - * illagers' crossbow hold ({@code arms_*}), armor-stand arms ({@code arms}), chests ({@code chest}) and the - * state poses of {@link EntityState#pose()} (sleeping, sitting, crouching, lying, swimming, in_water, …; - * flags the type's dump doesn't know are ignored). Falls back to fewer flags when the exact combination has - * no own variant (identical to a simpler pose): state-pose subsets from all to none, each first with all - * other flags, then without the held-item flags (e.g. zombies' arms ignore held items), then with the - * part-toggling flags only. + * The dumped pose variant (per body model: baby/small models have their own pivots) for the entity's flags: + * {@code passenger}, {@code item_r}/{@code item_l}, {@code arms}, {@code chest} and the state poses of + * {@link EntityState#pose()} the dump knows (sleeping, crouching, {@code arms_}, …). Combinations + * without an own variant equal a simpler pose: state-pose subsets from all to none, each with all other flags, + * then without the held-item flags (e.g. zombies' arms ignore held items). */ private SelectedPose selectPose(EntityState s, CemModelLoader.EntityInfo info, Choice ch) { if (info == null) return SelectedPose.NONE; - String sub = ch == null || ch.name.equals(ch.base) || !ch.name.startsWith(ch.base + "/") + String sub = ch.name.equals(ch.base) || !ch.name.startsWith(ch.base + "/") ? null : ch.name.substring(ch.base.length() + 1); CemModelLoader.PoseSet set = info.poseSet(sub); if (set.poses().isEmpty()) return SelectedPose.NONE; - // Held-item arm poses (players' ITEM pose) only exist where vanilla has them; mob arm poses (illagers' - // crossbow hold, piglins' admiring/attacking, …) come as "arms_" state flags from the snapshot. List flags = new ArrayList<>(); if (s.rightHand() != null) flags.add("item_r"); if (s.leftHand() != null) flags.add("item_l"); @@ -311,7 +289,7 @@ public final class CemBaker implements EntityBaker { if (s.chest()) flags.add("chest"); List states = new ArrayList<>(); if (s.pose() != null) { - java.util.Set known = knownPoseFlags(set); + Set known = knownPoseFlags(set); for (String f : s.pose().split("\\+")) if (known.contains(f) && !states.contains(f)) states.add(f); } List> tries = new ArrayList<>(); @@ -322,26 +300,23 @@ public final class CemBaker implements EntityBaker { List noItems = new ArrayList<>(st); flags.stream().filter(f -> !f.startsWith("item_")).forEach(noItems::add); tries.add(noItems); - List toggles = new ArrayList<>(st); - flags.stream().filter(PART_TOGGLES::contains).forEach(toggles::add); - tries.add(toggles); } for (List t : tries) { if (t.isEmpty()) continue; String name = String.join("+", t.stream().sorted().toList()); Map p = set.poses().get(name); - if (p != null) return new SelectedPose(name, p, set.transforms().get(name)); + if (p != null) return new SelectedPose(p, set.transforms().get(name)); } return SelectedPose.NONE; } - /** Every flag that occurs in a pose name of the set (cached per loaded pose map). */ - private final Map>, java.util.Set> knownFlags = - java.util.Collections.synchronizedMap(new java.util.IdentityHashMap<>()); + // Every flag occurring in a pose name, per loaded pose map. + private final Map>, Set> knownFlags = + Collections.synchronizedMap(new IdentityHashMap<>()); - private java.util.Set knownPoseFlags(CemModelLoader.PoseSet set) { + private Set knownPoseFlags(CemModelLoader.PoseSet set) { return knownFlags.computeIfAbsent(set.poses(), poses -> { - java.util.Set out = new java.util.HashSet<>(); + Set out = new HashSet<>(); for (String n : poses.keySet()) out.addAll(List.of(n.split("\\+"))); return out; }); @@ -389,7 +364,7 @@ public final class CemBaker implements EntityBaker { any |= Math.abs(delta[j]) > 1e-6; } if (!any) continue; - if (out == null) out = new java.util.HashMap<>(parts); + if (out == null) out = new HashMap<>(parts); for (Map.Entry pc : b.getValue().parts().entrySet()) { double[] c = pc.getValue(); double[] add = new double[3]; @@ -405,15 +380,11 @@ public final class CemBaker implements EntityBaker { } /** - * Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), babies - * T(0, 1/16, -4.5/16), - * third-person display context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · - * rotX(180), ground context) and head items (CustomHeadLayer: blocks/items at 0.625 with the "head" - * context, skulls at 1.1875 through the block-entity model). Frames are Java-model frames (blocks). + * Held items (ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), third-person + * context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · rotX(180), ground context; illagers + * hide held items while their arms are crossed) and head items (CustomHeadLayer). */ private void addItems(EntityState s, Map frames, boolean illager, List out) { - // Frames exist only for drawn parts: a visible arm holds the item; with crossed arms only villager- - // like models show it (illagers hide held items while their arms are crossed). String d = CemGeometry.DRAWN; boolean crossed = frames.containsKey(d + "arms") && !frames.containsKey(d + "right_arm") && !illager; for (int side = 0; side < 2; side++) { @@ -422,7 +393,7 @@ public final class CemBaker implements EntityBaker { if (item == null) continue; Affine arm = frames.get(left ? "left_arm" : "right_arm"); if (arm != null && !crossed && !(illager && !frames.containsKey(d + "right_arm"))) { - // ItemInHandLayer.useBabyOffset (babies except armor stands): (0, 1, -4.5) px instead of (±1, 2, -10). + // ItemInHandLayer.useBabyOffset (babies except armor stands). boolean babyOffset = s.baby() && !s.typeKey().equals("armor_stand"); Affine x = arm.mul(CemGeometry.JAVA_LOCAL) .mul(Affine.rotX(Math.toRadians(-90))) @@ -446,10 +417,10 @@ public final class CemBaker implements EntityBaker { if (head != null && h != null) { Affine hj = h.mul(CemGeometry.JAVA_LOCAL); if (head.blockEntity() != null && head.blockEntity().kind() == BlockEntityState.Kind.HEAD) { - // CustomHeadLayer: head frame · S(1.1875) · SkullBlockRenderer.submitSkull (bare skull model). + // CustomHeadLayer: head frame · S(1.1875) · the bare skull model. items.bakeBlockEntityModel(head.blockEntity(), hj.mul(Affine.scale(1.1875)), out); } else { - // Snow golems wear their pumpkin a bit lower (SnowGolemHeadLayer) than CustomHeadLayer items. + // SnowGolemHeadLayer sits lower than CustomHeadLayer items. double dy = s.typeKey().equals("snow_golem") ? -0.34375 : -0.25; Affine x = hj.mul(Affine.translation(0, dy, 0)) .mul(Affine.rotY(Math.PI)) @@ -460,12 +431,9 @@ public final class CemBaker implements EntityBaker { } /** - * Item carried in the mouth, rendered in the "ground" context like vanilla (HoldingEntityRenderState). - * Fox (FoxHeldItemLayer): head PIVOT (the layer turns by the head-look angles, not the posed head part: - * PixelPics has no head look) · [baby: S(0.75)] · T(0.06, 0.27 [baby 0.26], -0.5) · rotX(90); asleep - * T(0.46, 0.26, 0.22) [baby (0.4, 0.26, 0.15)] · rotX(90) · rotZ(90). Dolphin (DolphinCarryingItemLayer) at - * the model root · T(0, 1 − a·0.5, −1 + a·0.5) looking up / T(0, 1 + a·0.8, −1 + a·0.2) looking down, - * a = |pitch| / 60. + * Mouth item, "ground" context. FoxHeldItemLayer: head PIVOT (the layer turns by the head-look angles, not the + * posed head part; PixelPics has no head look) · [baby S(0.75)] · offsets per sleeping/baby. DolphinCarrying- + * ItemLayer: model root · offset by a = |pitch| / 60, up or down. */ private void addMouthItem(EntityState s, Map frames, List out) { ItemSpec item = s.mouthItem(); @@ -494,13 +462,10 @@ public final class CemBaker implements EntityBaker { if (x != null) items.bake(item, "ground", false, x, out); } - /** - * Parrots on a player's shoulders (vanilla ParrotOnShoulderLayer): the parrot model in its ON_SHOULDER pose, - * in the player's model space at T(±0.4, -1.5 [crouching -1.3], 0), textured by its variant. - */ + /** ParrotOnShoulderLayer: the parrot in its on_shoulder pose at T(±0.4, -1.5 [crouching -1.3], 0). */ private void addShoulderParrots(EntityState s, Choice ch, Affine t0, Out out) { - if (t0 == null || (s.shoulderLeft() == null && s.shoulderRight() == null)) return; - if (ch == null || !ch.base.startsWith("player")) return; + if (s.shoulderLeft() == null && s.shoulderRight() == null) return; + if (!ch.base.startsWith("player")) return; CemModelLoader.CemModel pm = models.vanilla("parrot"); CemModelLoader.EntityInfo pi = models.entityInfo("parrot"); if (pm == null || pi == null) return; @@ -516,9 +481,7 @@ public final class CemBaker implements EntityBaker { } if (tex == null && pi.texture() != null) tex = texture(pi.texture()); if (tex == null) continue; - Affine pre = t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0)) - .mul(Affine.scale(1 / 16.0)) - .mul(CemGeometry.CEM_TO_JAVA); + Affine pre = CemGeometry.modelSpace(t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0))); out.lit.addAll(CemGeometry.bakePosed(pm, renderTexture(pm, tex), pre, parts, null)); } } @@ -530,21 +493,20 @@ public final class CemBaker implements EntityBaker { // --- model choice -------------------------------------------------------------------------------- /** - * Picks the body model: a baby model for babies when one exists, a variant-specific model (cow/pig/ - * chicken temperature models, salmon size, pufferfish puff state, …) matched by name token, else the - * type's main model; falls back to the legacy CEM template mapping. + * The body model: baby model for babies, variant model matched by name token (temperature variants, salmon + * size, puff state, …), else the main model; else the legacy CEM template. */ private Choice chooseModel(EntityState s) { String type = s.typeKey(); if (s.player()) type = s.slim() && models.hasVanilla("player_slim") ? "player_slim" : "player"; String legacy = EntityModels.cemModel(type); - // Types whose dump entry only exists under another type (mannequin -> player) reuse that base. + // Types without an own dump entry reuse another's (mannequin -> player). String base = models.hasVanilla(type) || !subModels(type).isEmpty() ? type : legacy; if (base.equals("player") && s.slim() && models.hasVanilla("player_slim")) base = "player_slim"; String variant = s.variant() == null ? null : s.variant().toLowerCase(Locale.ROOT); CemModelLoader.VariantAssets va = models.variant(type, s.variant()); - if (va != null) variant = va.model(); // data-driven variant: its model kind ("cold", …; null = normal) + if (va != null) variant = va.model(); // "cold", …; null = normal if (type.equals("tropical_fish") && variant != null) { variant = EntityModels.tropicalPattern(variant) >= 6 ? "large" : "small"; // pattern -> body shape } @@ -559,7 +521,6 @@ public final class CemBaker implements EntityBaker { String n = findSub(base, variant, false); if (n != null) return vanilla(n, base, false); if (models.hasVanilla(base)) return vanilla(base, base, false); - // Legacy CEM-template model (rest pose, ground-snapped). CemModelLoader.CemModel lm = models.get(legacy); return lm == null ? null : new Choice(lm, legacy, base, false); } @@ -606,7 +567,7 @@ public final class CemBaker implements EntityBaker { return namesCached("E:" + base, n -> n.startsWith(base + "/equip.")); } - private List namesCached(String key, java.util.function.Predicate filter) { + private List namesCached(String key, Predicate filter) { return subModelCache.computeIfAbsent(key, k -> { List out = new ArrayList<>(); for (String n : models.vanillaNames()) if (filter.test(n)) out.add(n); @@ -629,65 +590,56 @@ public final class CemBaker implements EntityBaker { if (lm == null) continue; switch (layerClass) { case "SheepWoolLayer", "SheepWoolUndercoatLayer" -> { - // Wool: unless sheared. Undercoat (SheepWoolUndercoatLayer): only adults (no baby model) with - // non-white or jeb_ wool ("undercoat" flag from the snapshot) — sheared or not. + // Undercoat: only adults with non-white or jeb_ wool (snapshot flag), sheared or not. boolean undercoat = layerClass.equals("SheepWoolUndercoatLayer"); if (undercoat ? !s.hasPose("undercoat") : "sheared".equals(s.variant())) continue; - if (lm.texture() == null) continue; - int[][] t = texture(lm.texture()); + int[][] t = layerTexture(lm); if (t == null) continue; + t = tinted(t, s.tint()); if (undercoat || baby) { - // Undercoat and baby wool have the body's mesh (no inflation; coloredCutoutModelCopyLayerRender - // draws them on top): an overlay on its surface. - addOverlay(out.lit, pose.bake(lm, renderTexture(lm, tinted(t, s.tint())))); + // The body's own mesh (coloredCutoutModelCopyLayerRender): an overlay on its surface. + addOverlay(out.lit, pose.bake(lm, renderTexture(lm, t))); } else { - add(out, pose, lm, tinted(t, s.tint())); + add(out, pose, lm, t); } } case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, out); case "CatCollarLayer" -> { - // Own collar model (inflated by 0.01), collar texture tinted with the collar's dye colour. - if (s.collar() == 0 || lm.texture() == null) continue; - int[][] t = texture(lm.texture()); - if (t == null) continue; - add(out, pose, lm, tinted(t, s.collar())); + int[][] t = s.collar() == 0 ? null : layerTexture(lm); + if (t != null) add(out, pose, lm, tinted(t, s.collar())); } case "CreeperPowerLayer", "WitherArmorLayer" -> { - // EnergySwirlLayer: charged creeper / wither below half health; drawn additively. + // EnergySwirlLayer, drawn additively. if (!s.powered() || lm.texture() == null) continue; int[][] t = energySwirl(lm.texture(), layerClass.equals("WitherArmorLayer")); if (t != null) out.glow.addAll(pose.bake(lm, t)); } case "SlimeOuterLayer" -> { - // Translucent outer cube on the body texture (alpha < 255 there; blended by the tracer). int[][] t = lm.texture() != null ? texture(lm.texture()) : bodyTex; if (t != null) add(out, pose, lm, t); } case "LlamaDecorLayer", "WolfArmorLayer" -> { - // Body equipment drawn by a dedicated layer: texture folder _body (trader llama: llama_body). + // Texture folder _body (trader llama: llama_body). if (s.bodyEquip() == null) continue; int[][] t = equipmentTexture(base + "_body", s.bodyEquip(), baby, s.bodyDye()); - if (t == null) { + if (t == null && base.contains("_")) { t = equipmentTexture( base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye()); } if (t != null) add(out, pose, lm, t); } case "RopesLayer" -> { - if (s.bodyEquip() == null || lm.texture() == null) continue; // ropes only with a harness - int[][] t = texture(lm.texture()); + int[][] t = s.bodyEquip() == null ? null : layerTexture(lm); // only with a harness if (t != null) add(out, pose, lm, t); } default -> { - // Layers vanilla draws for the default state (drowned/stray/bogged outer, breeze wind, …); - // state-dependent ones (charged, tamed, equipped, …) are handled above or not drawn. - if (info == null || !info.layerDrawnByDefault(layerClass) || lm.texture() == null) continue; - int[][] t = texture(lm.texture()); + // Other layers only if vanilla draws them for the default state (drowned outer, breeze wind, …). + int[][] t = info == null || !info.layerDrawnByDefault(layerClass) ? null : layerTexture(lm); if (t != null) add(out, pose, lm, t); } } } - // Equipment layers (saddles, body armor/harness/carpets), named /equip... + // Equipment layers /equip.. (saddles, body armor, harness, carpets). for (String n : equipModels(base)) { String rest = n.substring(base.length() + "/equip.".length()); String layerType = rest.substring(0, rest.indexOf('.')); @@ -705,18 +657,23 @@ public final class CemBaker implements EntityBaker { } } - /** Tropical fish pattern overlay: pattern texture (tinted with the pattern colour) on the pattern model. */ + /** The layer model's own texture, or null. */ + private int[][] layerTexture(CemModelLoader.CemModel lm) { + return lm.texture() == null ? null : texture(lm.texture()); + } + + /** Tropical fish pattern texture, tinted with the pattern colour. */ private void addTropicalPattern(EntityState s, String name, CemModelLoader.CemModel lm, Pose pose, Out out) { int idx = EntityModels.tropicalPattern(s.variant()); if (idx < 0) return; - boolean small = idx < 6; // patterns 0-5 use shape A (small model), 6-11 shape B (large model) + boolean small = idx < 6; // patterns 0-5: shape A (small model), 6-11: shape B if (name.toLowerCase(Locale.ROOT).contains("small") != small) return; int[][] t = texture("entity/fish/tropical_" + (small ? "a" : "b") + "_pattern_" + (idx % 6 + 1)); if (t == null) return; add(out, pose, lm, tinted(t, s.tint2())); } - /** entity/equipment// (+ a "_carpet"-stripped name; leather dye + overlay); cached. */ + /** {@code entity/equipment//} (baby, "_carpet"-stripped fallbacks; dye + overlay); cached. */ private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) { return derived.get( DerivedTextureCache.key("equip", layerType, asset, baby, dye), @@ -748,16 +705,14 @@ public final class CemBaker implements EntityBaker { // --- energy swirl & collars ---------------------------------------------------------------------- - // A photo has no time: the swirl's scrolling texture is frozen at this age (ticks). Chosen so the sparse - // wither_armor pattern covers every wither part (at e.g. 42 the side heads land on empty rows). + // A photo has no time: the swirl scroll is frozen at this age (ticks), chosen so the sparse wither_armor pattern + // covers every wither part (at e.g. 42 the side heads land on empty rows). private static final float SWIRL_AGE = 59f; /** - * Energy-swirl texture (vanilla EnergySwirlLayer / RenderTypes.energySwirl): the texture matrix shifts the UV - * by ({@code xOffset(age) % 1}, {@code age·0.01 % 1}) on a repeating texture — baked here as a wrapped copy - * of the texture rolled by that offset — and the vertex colour 0xFF808080 halves the RGB. The result is - * drawn additively (BlendFunction.ADDITIVE), so alpha only cuts out (shader ALPHA_CUTOUT 0.1). - * xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}. + * EnergySwirlLayer texture: the texture matrix shifts the UV by ({@code xOffset(age) % 1}, {@code age·0.01 % 1}) + * (xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}), baked as a rolled copy; vertex colour + * 0xFF808080 halves the RGB. Drawn additively, so alpha only cuts out. */ private int[][] energySwirl(String path, boolean wither) { return derived.get(DerivedTextureCache.key("swirl", path, wither), () -> buildEnergySwirl(path, wither)); @@ -781,7 +736,7 @@ public final class CemBaker implements EntityBaker { return out; } - /** Body texture with the dye-tinted {@code entity//_collar[_baby]} composited over it. */ + /** Body texture with the dyed {@code entity//_collar[_baby]} over it. */ private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) { return derived.get( DerivedTextureCache.key("collar", tex, base, baby, dye), () -> buildWithCollar(tex, base, baby, dye)); @@ -799,30 +754,23 @@ public final class CemBaker implements EntityBaker { /** {@code src} multiplied by an ARGB tint (cached; {@code src} itself for no tint). */ private int[][] tinted(int[][] src, int argb) { if (argb == 0) return src; - return derived.get(DerivedTextureCache.key("tint", src, argb), () -> { - int[][] out = TextureOps.deepCopy(src); - TextureOps.tint(out, argb); - return out; - }); + return derived.get(DerivedTextureCache.key("tint", src, argb), () -> TextureOps.tinted(src, argb)); } // --- emissive layers ----------------------------------------------------------------------------- /** - * Emissive layers of the dump ({@link CemModelLoader.EmissiveLayer}: spider / enderman / phantom / breeze / - * copper-golem / creaking eyes, warden bioluminescence): the layer's texture (per variant, e.g. copper-golem - * weathering) on its model, processed by its render type (eyes: blended and unshaded; translucent emissive: - * blended, shaded), times the static layer alpha. Skipped: animated layers (warden pulsating spots), layers - * whose state flag is off (creaking eyes glow only while active), and on invisible entities those vanilla - * hides (EyesLayer draws even then). + * The dump's emissive layers (eyes, warden bioluminescence, …): texture per variant, processed by the layer's + * render type, times its static alpha. Skipped: animated layers (a photo has no age), layers whose state flag is + * off (creaking eyes), and on invisible entities those vanilla hides. */ private void addEmissiveLayers(EntityState s, Choice ch, CemModelLoader.EntityInfo info, Pose pose, Out out) { - if (info == null || ch == null) return; + if (info == null) return; for (CemModelLoader.EmissiveLayer e : info.emissive()) { if (e.animated() || e.alpha() <= 1e-5) continue; if (e.when() != null && !s.hasPose(e.when())) continue; if (s.invisible() && !e.invisible()) continue; - // A layer drawing the parent model follows the chosen body model. + // A layer drawing the parent model follows the chosen body model (baby, variant). CemModelLoader.CemModel m = e.model().equals(ch.base) ? ch.model : models.vanilla(e.model()); if (m == null) continue; String variantTex = @@ -832,7 +780,6 @@ public final class CemBaker implements EntityBaker { CemModelLoader.RenderType rt = models.renderType(e.renderType()); t = renderTexture(rt, t); if (e.alpha() < 1) t = withAlpha(t, e.alpha()); - // Overlay on the coplanar body surface (see OVERLAY_INFLATE). addOverlay(rt != null && !rt.shaded() ? out.unshaded : out.lit, pose.bake(m, t)); } } @@ -858,22 +805,17 @@ public final class CemBaker implements EntityBaker { } /** - * Player skin, else a render-state texture of the dump ({@code stateTextures}: tipped arrow, invulnerable - * wither) whose flag the entity has, else the data-driven variant's asset (baby / tame / angry aware), else the - * variant-aware candidates - * ({@link EntityModels}), the vanilla renderer's default texture as a generic fallback, and — for a - * dedicated baby model — the {@code _baby} counterparts first. + * Player skin, else a dumped state texture whose flag is set (tipped arrow, invulnerable wither), else the + * registry variant's asset, else the {@link EntityModels} candidates with the renderer's default texture (baby + * model: the {@code _baby} counterparts first). */ - private int[][] resolveTexture(EntityState s, Choice ch) { + private int[][] resolveTexture(EntityState s, CemModelLoader.EntityInfo info, boolean babyModel) { if (s.player()) { int[][] skin = skins.get(s.skinUrl()).orElse(null); if (skin != null) return skin; int[][] def = texture(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"); if (def != null) return def; } - boolean babyModel = ch != null && ch.babyModel; - CemModelLoader.EntityInfo info = models.entityInfo(s.typeKey()); - if (info == null && ch != null) info = models.entityInfo(ch.base); if (info != null && s.pose() != null) { for (Map.Entry st : info.stateTextures().entrySet()) { if (!s.hasPose(st.getKey())) continue; @@ -881,7 +823,6 @@ public final class CemBaker implements EntityBaker { if (t != null) return t; } } - // Data-driven variant (cat/wolf/frog/cow/pig/chicken/…): the texture its definition names. CemModelLoader.VariantAssets va = models.variant(s.typeKey(), s.variant()); String asset = va == null ? null : va.texture(babyModel, s.collar() != 0, s.hasPose("angry")); if (asset != null) { @@ -908,12 +849,12 @@ public final class CemBaker implements EntityBaker { return null; } - // --- villager texture compositing (biome type + profession + level badge over the base body) --- + // --- villagers: biome type + profession + level badge over the base body --- private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"}; private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) { if (base == null) return null; - // Only the inputs the composite reads: babies ignore profession and level. + // Only the inputs the composite reads (babies: no profession). String prof = babyModel ? null : s.profession(); int level = babyModel ? 0 : s.villagerLevel(); return derived.get( @@ -924,7 +865,6 @@ public final class CemBaker implements EntityBaker { private int[][] buildVillager(EntityState s, int[][] base, String folder, boolean babyModel) { int[][] out = TextureOps.deepCopy(base); if (babyModel) { - // Baby villagers: their own biome-type clothing set; no profession. if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/baby/" + s.variant()); return out; } @@ -940,26 +880,24 @@ public final class CemBaker implements EntityBaker { return out; } - /** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */ + /** Overlays {@code path} onto {@code dst} if it exists and has the same size (HD-pack guard). */ private void overlayIfPresent(int[][] dst, String path) { int[][] o = texture(path); - if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch + if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; TextureOps.overlay(dst, o); } - // --- humanoid armor --------------------------------------------------------------------------- - // Per-slot armor models of the wearer's renderer (/HumanoidArmorLayer.modelSet.), posed - // like the body — raised zombie arms included. + // --- humanoid armor: the wearer's per-slot models /HumanoidArmorLayer.modelSet., posed like the body + private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple private void addArmorLayers(EntityState s, Choice ch, Pose pose, Out out) { EntityState.Equipment eq = s.equipment(); - String base = ch == null ? s.typeKey() : ch.base; - boolean baby = ch != null && ch.babyModel; + String base = ch.base; + boolean baby = ch.babyModel; String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + "."; - // HumanoidArmorLayer: babies use the humanoid_baby equipment textures — except small armor stands, whose - // armor models are the adult ones scaled (ARMOR_STAND_SMALL_ARMOR = BABY_TRANSFORMER over the adult - // layer, adult UVs) and keep humanoid / humanoid_leggings. + // HumanoidArmorLayer: babies use humanoid_baby textures, except small armor stands (ARMOR_STAND_SMALL_ARMOR = + // the adult layer scaled, adult UVs). boolean babyTextures = baby && !s.typeKey().equals("armor_stand"); String folder = babyTextures ? "humanoid_baby" : "humanoid"; bakeArmorPiece(eq.head(), set + "head", folder, pose, out); @@ -972,7 +910,6 @@ public final class CemBaker implements EntityBaker { bakeArmorPiece(eq.feet(), set + "feet", folder, pose, out); } - /** Bake one armor slot: its vanilla per-slot model, textured for the material. */ private void bakeArmorPiece( EntityState.EquipPiece piece, String modelName, String layerFolder, Pose pose, Out out) { if (piece == null) return; @@ -983,7 +920,7 @@ public final class CemBaker implements EntityBaker { if (tex != null) add(out, pose, model, tex); } - /** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */ + /** Material texture (+ leather dye and overlay), trim, glint. */ private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) { String asset = piece.asset(); int[][] base = texture("entity/equipment/" + layerFolder + "/" + asset); @@ -1000,7 +937,7 @@ public final class CemBaker implements EntityBaker { return out; } - /** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */ + /** Armor trim: the grayscale pattern recoloured by the material palette, overlaid (same UV). */ private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) { int[][] mask = texture("trims/entity/" + layerFolder + "/" + pattern); if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return; @@ -1034,8 +971,9 @@ public final class CemBaker implements EntityBaker { int[][] tex = texture("entity/equipment/wings/elytra"); if (tex == null) return; if (piece.glint()) { - tex = derived.get(DerivedTextureCache.key("elytra_glint", tex), () -> { - int[][] glinted = TextureOps.deepCopy(texture("entity/equipment/wings/elytra")); + int[][] src = tex; + tex = derived.get(DerivedTextureCache.key("elytra_glint", src), () -> { + int[][] glinted = TextureOps.deepCopy(src); applyGlint(glinted); return glinted; }); @@ -1043,25 +981,16 @@ public final class CemBaker implements EntityBaker { add(out, pose, model, tex); } + /** A textured (or grey) box of the entity's size when no model renders. */ private RenderedEntity fallbackBox(EntityState s, int[][] tex) { double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale(); - double[] from = {-w / 2, 0, -w / 2}; - double[] to = {w / 2, h, w / 2}; - int[][] t = tex != null ? tex : flat(0xFF8C8C8C); - ModelCube box = - new ModelCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w, h, w}, 0, new double[] {0, 0}, false); + int[][] t = tex != null ? tex : new int[][] {{0xFF8C8C8C}}; + ModelCube box = new ModelCube(new double[] {w, h, w}, new double[] {0, 0}, false, null); Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h)))); Affine place = Affine.translation(s.x(), s.y(), s.z()) .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) .mul(Affine.scale(1.0 / 16.0)); - List cubes = new ArrayList<>(); - cubes.add(new EntityCube(from, to, faces, place)); - return RenderedEntity.of(cubes); - } - - private static int[][] flat(int argb) { - int[][] t = new int[1][1]; - t[0][0] = argb; - return t; + return RenderedEntity.of(List.of( + new EntityCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w / 2, h, w / 2}, faces, place))); } } 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 index e2a8b64..1e4c8a0 100644 --- 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 @@ -3,6 +3,7 @@ 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 java.util.ArrayList; import java.util.List; @@ -10,10 +11,8 @@ import java.util.Map; import java.util.Set; /** - * Shared, stateless CEM geometry baking used by {@link CemBaker} (mobs), the block-entity baker and - * item geometry. 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. + * Turns a {@link CemModelLoader.CemModel} into {@link Baked} boxes (local box + model→pre-space transform) for + * mobs, block entities and items; placing them in the world is up to the caller. */ final class CemGeometry { @@ -24,7 +23,19 @@ final class CemGeometry { // Java-model block offsets -> CEM px offsets of a part frame (x/y flip, 16 px per block, no translation). static final Affine JAVA_LOCAL = new Affine(new double[] {-16, 0, 0, 0, -16, 0, 0, 0, 16}, new double[] {0, 0, 0}); - /** A baked box: local min/max corner, its six faces and the transform mapping it into pre-space. */ + /** Model px (CEM frame) → the Java-model block space {@code x} maps. */ + static Affine modelSpace(Affine x) { + return x.mul(Affine.scale(1 / 16.0)).mul(CEM_TO_JAVA); + } + + /** {@link #bakeModel} as cubes. */ + static void bakeCubes( + CemModelLoader.CemModel model, int[][] tex, Affine pre, Set hidden, List out) { + for (Baked b : bakeModel(model, tex, pre, hidden)) { + out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world())); + } + } + record Baked(double[] from, double[] to, Face[] faces, Affine world) { double minWorldY() { double m = Double.MAX_VALUE; @@ -38,17 +49,15 @@ final class CemGeometry { } } - /** Bake all parts of a model with the given pre-transform; parts in {@code hidden} are skipped. */ + /** Rest pose; parts in {@code hidden} are skipped. */ static List bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set hidden) { return bake(model, tex, pre, hidden, Map.of(), null); } /** - * Posed variant for vanilla ("absolute") models: {@code overrides} replace parts' pivot/rotation/visibility - * by name (pose variants: sitting, holding items, armor-stand arms, chests, …; boxes follow their pivot; - * a visibility-only override keeps the part's own pivot); {@code frames}, - * when non-null, receives each part's frame {@code parent·T(P)·R·S} (pre-space, maps a part-local CEM px - * offset) — used to attach held/head items to hands and heads. + * Vanilla models with pose {@code overrides} by part name (pivot/rotation/visibility/scale; boxes follow an + * overridden pivot). {@code frames}, when non-null, receives each part's frame {@code parent·T(P)·R·S} (also + * {@link #PIVOT} and {@link #DRAWN} entries), used to attach items to hands and heads. */ static List bakePosed( CemModelLoader.CemModel model, @@ -69,7 +78,7 @@ final class CemGeometry { Ctx ctx = new Ctx(hidden, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames); for (CemModelLoader.CemPart p : model.parts()) { if (model.absolute()) { - bakeAbsolute(p, pre, ctx); + bakeAbsolute(p, pre, ctx, true); } else { double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]}; bakePart(p, pre, o, 0, ctx); @@ -78,10 +87,8 @@ final class CemGeometry { return ctx.out; } - /** Key prefix in the frames map marking parts that were actually drawn (visible). */ + // Frame-map key prefixes: parts that were drawn, and a part's pivot frame parent·T(P) (without its rotation). static final String DRAWN = "drawn:"; - - /** Key prefix in the frames map for a part's pivot frame {@code parent·T(P)} (without its own rotation). */ static final String PIVOT = "pivot:"; private record Ctx( @@ -94,18 +101,10 @@ final class CemGeometry { Map frames) {} /** - * Vanilla-dumped ("absolute") part: pivot and boxes are absolute model px, so the group transform is - * simply {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} — the same order as vanilla's - * {@code ModelPart.translateAndRotate}. Invisible parts (vanilla {@code visible=false}, or hidden by a pose - * override) skip their whole subtree; {@code skipDraw} parts only skip their own boxes. - */ - private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx) { - bakeAbsolute(part, parentWorld, ctx, true); - } - - /** - * {@code draw=false} walks a hidden subtree only to record part frames: vanilla attaches held items to - * an arm's transform even when the arm itself isn't drawn (armor stands without arms, …). + * Vanilla ("absolute") part: {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} like + * {@code ModelPart.translateAndRotate}. Invisible parts hide their subtree, {@code skipDraw} only their own + * boxes. With frames requested, a hidden subtree is still walked ({@code draw=false}): vanilla attaches held + * items to an arm even when it isn't drawn (armor stands without arms). */ private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) { if (ctx.hidden.contains(part.name())) return; @@ -117,7 +116,7 @@ final class CemGeometry { } double[] scale = ov != null && ov.scale() != null ? ov.scale() : part.scale(); double[] add = ov == null ? null : ov.rotateAdd(); - boolean moved = ov != null && ov.pivot() != null; // else visibility/scale/extra-rotation only + boolean moved = ov != null && ov.pivot() != null; double[] o = moved ? ov.pivot() : part.pivot(); double[] rot = moved ? ov.rotate() : part.rotate(); if (add != null) rot = new double[] {rot[0] + add[0], rot[1] + add[1], rot[2] + add[2]}; @@ -139,8 +138,7 @@ final class CemGeometry { world = world.mul(Affine.translation(-o[0], -o[1], -o[2])); Affine boxWorld = world; if (moved) { - // Boxes are stored relative to the idle pivot: move them along with the overridden pivot - // (children carry their own absolute pivots, so they use the un-shifted transform). + // Boxes follow the overridden pivot; children carry their own absolute pivots. double[] p0 = part.pivot(); boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2])); } @@ -154,8 +152,8 @@ final class CemGeometry { double[] inf = b.inflate(); double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]}; double[] to = {org[0] + b.size()[0] + inf[0], org[1] + b.size()[1] + inf[1], org[2] + b.size()[2] + inf[2]}; - ModelCube mc = new ModelCube(org, b.size(), inf[0], b.uv(), b.mirror(), b.faceUv()); - // Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale). + ModelCube mc = new ModelCube(b.size(), b.uv(), b.mirror(), b.faceUv()); + // Vanilla texScale: UVs normalised by texSize·scale. int tw = ctx.texW, th = ctx.texH; if (b.texScale() != null) { tw = (int) Math.round(tw * b.texScale()[0]); @@ -169,10 +167,9 @@ final class CemGeometry { } /** - * 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)}. + * CEM template part (OptiFine/Blockbench import): {@code parent · T(O) · R · T(-O)} with origin {@code O} = + * {@code -translate} at the top level, else {@code translate}, accumulated with the parent origin from the + * 2nd nesting level on. Top-level boxes are absolute, nested ones offset by their group origin. */ private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) { if (ctx.hidden.contains(part.name())) return; @@ -190,7 +187,6 @@ final class CemGeometry { } 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, ctx); 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 c969072..6284cb5 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 @@ -4,42 +4,38 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; /** - * Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json} - * (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity - * models, already in the correct standing pose — no Bedrock geometry / animation needed. - * - *

Format per part: {@code coordinates} are ABSOLUTE model pixels, {@code translate} is the rotation - * pivot (negated), {@code rotate} is degrees. {@code invertAxis "xy"} is handled by the baker's flips. + * Loads CEM ({@code .jem} layout) entity models: the vanilla client dump ({@code tools/VanillaModelDump.java}, + * "absolute" models plus per-type renderer info, render types and registry variants) and the CEM template set + * (legacy entity fallback; part {@code translate} = negated pivot, box {@code coordinates} absolute px). */ public final class CemModelLoader { - /** - * 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). - */ + /** A box in px: min corner, size, per-axis inflation, box-UV offset (texels) and mirror. */ public record CemBox( double[] origin, double[] size, - double[] inflate, // per-axis grow (px) + double[] inflate, double[] uv, boolean mirror, - double[][] faceUv, + double[][] faceUv, // per-face {x, y, w, h} texels by Direction ordinal; null = box-UV double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1 boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn - /** 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. */ + /** A model part: CEM translate (legacy pivot rule, see {@link CemGeometry}), rotation (deg), boxes, children. */ public record CemPart( String name, double[] translate, @@ -52,9 +48,8 @@ public final class CemModelLoader { boolean skipDraw) {} // own boxes not drawn, children are /** - * A whole model: declared texture size and its top-level parts. {@code absolute} models (dumped from - * the vanilla client by {@code tools/VanillaModelDump.java}) carry absolute part pivots + boxes and the name - * of the render type vanilla draws them with ({@code renderType}, see {@link #renderType}; null = unknown). + * A model: declared texture size, top-level parts; {@code absolute} = vanilla dump (absolute pivots and boxes, + * {@code renderType} = the vanilla render type it is drawn with, null = unknown). */ public record CemModel( int texW, int texH, List parts, boolean absolute, String texture, String renderType) {} @@ -84,7 +79,7 @@ public final class CemModelLoader { boolean invisible, boolean animated) {} - /** Per-entity-type renderer info from the vanilla dump: extra render scale and default textures. */ + /** Per-entity-type renderer info from the vanilla dump. */ public record EntityInfo( String texture, String babyTexture, @@ -173,10 +168,6 @@ public final class CemModelLoader { return asset; } - private static String str(JsonObject o, String k) { - return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : null; - } - private static Map strMap(JsonObject o, String k) { Map out = new HashMap<>(); if (o.has(k) && o.get(k).isJsonObject()) { @@ -190,13 +181,12 @@ public final class CemModelLoader { } private final Map models = new HashMap<>(); - // Vanilla dump models live in their own namespace so they never shadow CEM-template names the - // legacy entity fallback relies on (the two sets use different names for different models). + // Own namespace: the dump and the CEM template set use the same names for different models. private final Map vanilla = new HashMap<>(); private final Map entities = new HashMap<>(); private final Map renderTypes = new HashMap<>(); // entity type -> variant key ("tabby", or ":" outside minecraft) -> assets - private final Map> variants = new java.util.concurrent.ConcurrentHashMap<>(); + private final Map> variants = new ConcurrentHashMap<>(); /** Assets of a data-driven variant of {@code typeKey} (registry {@code _variant}), or null. */ public VariantAssets variant(String typeKey, String key) { @@ -206,8 +196,7 @@ public final class CemModelLoader { /** Registers a (datapack) variant definition — the raw {@code _variant} JSON; replaces the vanilla one. */ public void addVariant(String typeKey, String key, JsonObject definition) { - variants.computeIfAbsent(typeKey, k -> new java.util.concurrent.ConcurrentHashMap<>()) - .put(key, VariantAssets.parse(definition)); + variants.computeIfAbsent(typeKey, k -> new ConcurrentHashMap<>()).put(key, VariantAssets.parse(definition)); } /** A model from the vanilla client dump, or null. */ @@ -219,8 +208,7 @@ public final class CemModelLoader { return vanilla.containsKey(name); } - /** All vanilla dump model names. */ - public java.util.Set vanillaNames() { + public Set vanillaNames() { return vanilla.keySet(); } @@ -234,89 +222,42 @@ public final class CemModelLoader { return entities.get(typeKey); } + /** A model of the CEM template set (legacy fallback), or null. */ public CemModel get(String name) { return models.get(name); } - public int size() { - return models.size(); - } - /** - * Parse a CEM template-models JSON stream (the bundled CEM template set, or the vanilla dump). Later - * loads override earlier models of the same name. Returns the total number of models loaded. + * Parses a CEM template set or a vanilla dump (recognised by its {@code entities}); later loads override + * models of the same name. Returns the total number of models loaded so far. */ public int load(InputStream in, Logger logger) { JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) .getAsJsonObject(); boolean isVanillaDump = root.has("entities"); + if (isVanillaDump) loadDumpInfo(root); Map target = isVanillaDump ? vanilla : models; - if (isVanillaDump) { - for (Map.Entry e : - root.getAsJsonObject("entities").entrySet()) { - JsonObject o = e.getValue().getAsJsonObject(); - entities.put( - e.getKey(), - new EntityInfo( - o.has("texture") ? o.get("texture").getAsString() : null, - o.has("babyTexture") ? o.get("babyTexture").getAsString() : null, - doubles(o, "transform"), - o.has("yawSign") ? o.get("yawSign").getAsInt() : -1, - doubles(o, "pitchAxis"), - poses(o.getAsJsonObject("poses")), - layers(o), - o.has("humanoidArmor") && o.get("humanoidArmor").getAsBoolean(), - poseTransforms(o.getAsJsonObject("poseTransforms")), - modelPoses(o.getAsJsonObject("modelPoses")), - rotationBindings(o.getAsJsonObject("rotationBindings")), - emissive(o.getAsJsonArray("emissive")), - strings(o.getAsJsonObject("stateTextures")))); - } - if (root.has("renderTypes")) { - for (Map.Entry t : - root.getAsJsonObject("renderTypes").entrySet()) { - JsonObject o = t.getValue().getAsJsonObject(); - renderTypes.put( - t.getKey(), - new RenderType( - o.has("cutout") ? o.get("cutout").getAsDouble() : -1, - o.has("blend") ? o.get("blend").getAsString() : null, - !o.has("shaded") || o.get("shaded").getAsBoolean())); - } - } - if (root.has("variants")) { - for (Map.Entry t : - root.getAsJsonObject("variants").entrySet()) { - Map m = new java.util.concurrent.ConcurrentHashMap<>(); - for (Map.Entry v : - t.getValue().getAsJsonObject().entrySet()) { - m.put(v.getKey(), VariantAssets.parse(v.getValue().getAsJsonObject())); - } - variants.put(t.getKey(), m); - } - } - } - JsonObject modelsObj = root.getAsJsonObject("models"); - for (Map.Entry e : modelsObj.entrySet()) { + for (Map.Entry e : root.getAsJsonObject("models").entrySet()) { try { JsonObject entry = e.getValue().getAsJsonObject(); if (!entry.has("model")) continue; - // The vanilla dump nests the jem as an object; the CEM template set (and dumps written before - // 2026-11) embed it as a JSON string that needs a second parse. + // The dump nests the jem as an object; the CEM template set (and older dumps) embed a JSON string. JsonElement m = entry.get("model"); JsonObject model = m.isJsonObject() ? m.getAsJsonObject() : JsonParser.parseString(m.getAsString()).getAsJsonObject(); - int tw = model.getAsJsonArray("textureSize").get(0).getAsInt(); - int th = model.getAsJsonArray("textureSize").get(1).getAsInt(); + JsonArray size = model.getAsJsonArray("textureSize"); List parts = new ArrayList<>(); for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject())); - boolean absolute = - model.has("absolute") && model.get("absolute").getAsBoolean(); - String texture = model.has("texture") ? model.get("texture").getAsString() : null; - String renderType = - model.has("renderType") ? model.get("renderType").getAsString() : null; - target.put(e.getKey(), new CemModel(tw, th, parts, absolute, texture, renderType)); + target.put( + e.getKey(), + new CemModel( + size.get(0).getAsInt(), + size.get(1).getAsInt(), + parts, + bool(model, "absolute"), + str(model, "texture"), + str(model, "renderType"))); } catch (Exception ex) { if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage()); } @@ -324,10 +265,55 @@ public final class CemModelLoader { return models.size() + vanilla.size(); } + private void loadDumpInfo(JsonObject root) { + for (Map.Entry e : root.getAsJsonObject("entities").entrySet()) { + JsonObject o = e.getValue().getAsJsonObject(); + entities.put( + e.getKey(), + new EntityInfo( + str(o, "texture"), + str(o, "babyTexture"), + doubles(o, "transform"), + o.has("yawSign") ? o.get("yawSign").getAsInt() : -1, + doubles(o, "pitchAxis"), + poses(o.getAsJsonObject("poses")), + layers(o.getAsJsonObject("layers")), + bool(o, "humanoidArmor"), + poseTransforms(o.getAsJsonObject("poseTransforms")), + modelPoses(o.getAsJsonObject("modelPoses")), + rotationBindings(o.getAsJsonObject("rotationBindings")), + emissive(o.getAsJsonArray("emissive")), + strings(o.getAsJsonObject("stateTextures")))); + } + if (root.has("renderTypes")) { + for (Map.Entry t : + root.getAsJsonObject("renderTypes").entrySet()) { + JsonObject o = t.getValue().getAsJsonObject(); + renderTypes.put( + t.getKey(), + new RenderType( + o.has("cutout") ? o.get("cutout").getAsDouble() : -1, + str(o, "blend"), + !o.has("shaded") || o.get("shaded").getAsBoolean())); + } + } + if (root.has("variants")) { + for (Map.Entry t : + root.getAsJsonObject("variants").entrySet()) { + Map m = new ConcurrentHashMap<>(); + for (Map.Entry v : + t.getValue().getAsJsonObject().entrySet()) { + m.put(v.getKey(), VariantAssets.parse(v.getValue().getAsJsonObject())); + } + variants.put(t.getKey(), m); + } + } + } + private CemPart parsePart(JsonObject p) { double[] translate = arr3(p, "translate"); double[] rotate = arr3(p, "rotate"); - boolean partMirror = mirrorsU(p); // mirrorTexture "u" — applies to all of the part's boxes + boolean partMirror = mirrorsU(p); // a part's mirrorTexture applies to all its boxes List boxes = new ArrayList<>(); if (p.has("boxes")) { for (JsonElement be : p.getAsJsonArray("boxes")) { @@ -351,17 +337,13 @@ public final class CemModelLoader { if (b.has("hiddenFaces")) { hiddenFaces = new boolean[6]; for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) { - hiddenFaces[ - eu.mhsl.minecraft.pixelpics.assets.model.Direction.fromName(hf.getAsString()) - .ordinal()] = true; + hiddenFaces[Direction.fromName(hf.getAsString()).ordinal()] = true; } } - double[] uv = b.has("textureOffset") - ? new double[] { - b.getAsJsonArray("textureOffset").get(0).getAsDouble(), - b.getAsJsonArray("textureOffset").get(1).getAsDouble() - } - : new double[] {0, 0}; + JsonArray to = b.has("textureOffset") ? b.getAsJsonArray("textureOffset") : null; + double[] uv = to == null + ? new double[] {0, 0} + : new double[] {to.get(0).getAsDouble(), to.get(1).getAsDouble()}; boxes.add(new CemBox( origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b), texScale, hiddenFaces)); } @@ -375,23 +357,16 @@ public final class CemModelLoader { : (p.has("id") ? p.get("id").getAsString() : ""); double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null; double[] scale = p.has("scale") ? arr3(p, "scale") : null; - boolean invisible = p.has("invisible") && p.get("invisible").getAsBoolean(); - boolean skipDraw = p.has("skipDraw") && p.get("skipDraw").getAsBoolean(); - return new CemPart(name, translate, rotate, boxes, children, pivot, scale, invisible, skipDraw); + return new CemPart( + name, translate, rotate, boxes, children, pivot, scale, bool(p, "invisible"), bool(p, "skipDraw")); } // 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. */ + /** Per-face UV ({@code uvNorth} etc., each {@code [u1,v1,u2,v2]}) as {@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; + if (Arrays.stream(FACE_UV_KEYS).noneMatch(b::has)) 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; @@ -483,11 +458,11 @@ public final class CemModelLoader { o.get("model").getAsString(), o.get("texture").getAsString(), strings(o.getAsJsonObject("textures")), - o.has("renderType") ? o.get("renderType").getAsString() : null, + str(o, "renderType"), o.has("alpha") ? o.get("alpha").getAsDouble() : 1, - o.has("when") ? o.get("when").getAsString() : null, - o.has("invisible") && o.get("invisible").getAsBoolean(), - o.has("animated") && o.get("animated").getAsBoolean())); + str(o, "when"), + bool(o, "invisible"), + bool(o, "animated"))); } return out; } @@ -502,14 +477,20 @@ public final class CemModelLoader { private static Map layers(JsonObject o) { Map out = new HashMap<>(); - if (o.has("layers")) { - for (Map.Entry e : o.getAsJsonObject("layers").entrySet()) { - out.put(e.getKey(), e.getValue().getAsBoolean()); - } - } + if (o == null) return out; + for (Map.Entry e : o.entrySet()) + out.put(e.getKey(), e.getValue().getAsBoolean()); return out; } + private static String str(JsonObject o, String k) { + return o.has(k) && o.get(k).isJsonPrimitive() ? o.get(k).getAsString() : null; + } + + private static boolean bool(JsonObject o, String k) { + return o.has(k) && o.get(k).getAsBoolean(); + } + private static double[] doubles(JsonObject o, String key) { if (!o.has(key) || !o.get(key).isJsonArray()) return null; JsonArray a = o.getAsJsonArray(key); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java index 55f6b57..6d0ddd6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/DatapackVariants.java @@ -9,6 +9,9 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -17,9 +20,8 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipFile; /** - * Registers the data-driven mob variants of the worlds' datapacks ({@code data//_variant/.json}, - * folder or zip packs) with the model loader, next to the vanilla ones from the dump — so a datapack cat/wolf/ - * cow/… variant renders with the texture its {@code asset_id} names (the resource pack must ship it). + * Registers the mob variants of the worlds' datapacks ({@code data//_variant/.json}, folder or + * zip packs) with the model loader, overriding the dumped vanilla ones (textures must be in the resource pack). */ public final class DatapackVariants { @@ -35,7 +37,7 @@ public final class DatapackVariants { */ public static int load(CemModelLoader models, Iterable worldFolders, Logger log) { int n = 0; - java.util.Set seen = new java.util.HashSet<>(); + Set seen = new HashSet<>(); for (File world : worldFolders) { File dir = world.getAbsoluteFile(); for (int up = 0; dir != null && up < 4 && !new File(dir, "datapacks").isDirectory(); up++) { @@ -72,7 +74,7 @@ public final class DatapackVariants { if (!zip.getName().endsWith(".zip")) return 0; int n = 0; try (ZipFile zf = new ZipFile(zip)) { - for (ZipEntry ze : java.util.Collections.list(zf.entries())) { + for (ZipEntry ze : Collections.list(zf.entries())) { if (ze.isDirectory()) continue; try (InputStream in = zf.getInputStream(ze)) { if (register(models, ze.getName(), in)) n++; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java index f926ece..2c521de 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java @@ -40,21 +40,16 @@ import org.bukkit.DyeColor; import org.bukkit.block.data.BlockData; /** - * Item (and block) geometry the way vanilla's ItemRenderer draws it, straight from the resource pack: - * the 26.x item definition ({@code items/.json}: model / special / select / condition) picks the - * model for the display context, the model's parent chain supplies the {@code display} transform and the - * {@code layerN} textures. Geometry: generated items as an extruded sprite (1px thick, like vanilla), - * block items as their block model, block-entity items (chest, heads, banners, …) through the block-entity - * baker, shield/trident through their entity models. Shared by held items, head items, item displays and - * dropped items. - * - *

All transforms are in vanilla item space: the caller's {@code toWorld} maps the ItemRenderer origin; - * this class applies the display transform and the {@code translate(-0.5)} to the 0..1 model cube. + * Item (and block) geometry like vanilla's ItemRenderer, from the resource pack: the 26.x item definition + * ({@code items/.json}) picks the model for the display context, its parent chain supplies the {@code display} + * transform and {@code layerN} textures. Generated items are extruded sprites (1 px thick), block items their block + * model, special models (chest, heads, banners, shield, trident, …) entity/block-entity models. The caller's + * {@code toWorld} maps the ItemRenderer origin; this class applies the display transform and {@code T(-0.5)}. */ public final class ItemGeometry { - // Tinted block faces have no biome context here: plains colours. - private static final BiomeTint DEFAULT_TINT = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4); + private static final Info EMPTY = new Info(null, List.of(), List.of(), null, null, null); + private static final double Z0 = 7.5 / 16, Z1 = 8.5 / 16; // extruded sprite depth private final AssetReader reader; private final BlockModelRegistry registry; private final TextureCache textures; @@ -89,7 +84,7 @@ public final class ItemGeometry { // --- public API -------------------------------------------------------------------------------- - /** Bake an item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */ + /** An item in a display context ("thirdperson_righthand", "head", "ground", "fixed", "none", …). */ public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List out) { if (item == null) return; Info info = info(item.id(), context); @@ -98,7 +93,7 @@ public final class ItemGeometry { if (info.special != null) bakeSpecial(item, info, m, out); if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(info, m, out); if (out.size() == before && item.block() != null) bakeBlock(item.block(), m, out); - // Placed block-entity fallback for block items only (a shield carries its patterns as a banner state). + // Placed block-entity fallback only for block items (a shield carries its patterns as a banner state). if (out.size() == before && item.block() != null && item.blockEntity() != null) { bakeBlockEntity(item.blockEntity(), m, out); } @@ -109,9 +104,9 @@ public final class ItemGeometry { } /** - * ItemStackRenderState.getModelBoundingBox: the model's quad extents through the display transform - * ({@code display·T(-0.5)}), as {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated - * items span their whole sprite square (the 16×16 front/back quads), not just the opaque texels. + * ItemStackRenderState.getModelBoundingBox: the model's extents through {@code display·T(-0.5)} as + * {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated items span their whole sprite + * square, not just the opaque texels. */ public double[] modelBounds(ItemSpec item, String context) { if (item == null) return null; @@ -119,58 +114,47 @@ public final class ItemGeometry { List cubes = new ArrayList<>(); if (info.special == null && !info.layers.isEmpty()) { Affine m = displayTransform(info.display, false).mul(Affine.translation(-0.5, -0.5, -0.5)); - cubes.add(new EntityCube(new double[] {0, 0, 7.5 / 16}, new double[] {1, 1, 8.5 / 16}, new Face[6], m)); + cubes.add(new EntityCube(new double[] {0, 0, Z0}, new double[] {1, 1, Z1}, new Face[6], m)); } else { bake(item, context, false, Affine.identity(), cubes); } if (cubes.isEmpty()) return null; - double[] box = { - Double.MAX_VALUE, - Double.MAX_VALUE, - Double.MAX_VALUE, - -Double.MAX_VALUE, - -Double.MAX_VALUE, - -Double.MAX_VALUE - }; - for (EntityCube c : cubes) { - for (int a = 0; a < 3; a++) { - box[a] = Math.min(box[a], c.aabbMin[a]); - box[a + 3] = Math.max(box[a + 3], c.aabbMax[a]); - } - } - return box; + RenderedEntity r = RenderedEntity.of(cubes); + return new double[] {r.aabbMin[0], r.aabbMin[1], r.aabbMin[2], r.aabbMax[0], r.aabbMax[1], r.aabbMax[2]}; } - /** A block's model elements in the block cube [0,1]³ (tinted with plains colours). */ + /** A block's model elements in the block cube [0,1]³. */ public void bakeBlock(BlockData data, Affine toWorld, List out) { if (data == null) return; bakeModel(registry.get(data), data, toWorld, out); } - /** A resolved block model's elements in the block cube [0,1]³; {@code data} = tint context or null. */ + /** A block model's elements in the block cube [0,1]³; {@code data} = tint context (plains colours) or null. */ public void bakeModel(ResolvedModel model, BlockData data, Affine toWorld, List out) { if (model == null || !model.hasGeometry) return; for (Element world : model.elements) { - // Rotated: bake the model-space element turned by the blockstate rotation, so textures stay bound - // to the model like vanilla (uvlocked faces carry their UV transform); otherwise the element as is. - Element e = world.unrotated != null ? world.unrotated : world; - Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity(); + // Blockstate-rotated: the model-space element turned as a whole, so textures stay bound to the model. + Element e = world; + Affine variant = Affine.identity(); + if (world.unrotated != null) { + e = world.unrotated; + variant = variantRotation(world.xSteps, world.ySteps); + } Face[] faces = new Face[6]; for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data); out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e)))); } } - /** A placed block-entity block (chest, head, banner, …) in the block cube [0,1]³ (block displays, …). */ + /** A block entity placed as in the world at cell (0,0,0), in the block cube [0,1]³ (block displays, …). */ public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List out) { - // Placed as in the world: cell (0,0,0) with the renderer's block pose. if (state == null || blockEntities == null) return; RenderedEntity r = blockEntities.bake(state); if (r == null) return; for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld))); } - /** A block-entity's bare models (no block pose) in Java model space mapped by {@code toWorld}. */ + /** A block entity's bare models (no block pose) mapped by {@code toWorld}. */ public void bakeBlockEntityModel(BlockEntityState state, Affine toWorld, List out) { if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out); } @@ -178,14 +162,14 @@ public final class ItemGeometry { // --- item definition / model resolution -------------------------------------------------------- private Info info(String id, String context) { - // Keyed by date too: minecraft:local_time selects (christmas chest items) change with the day. + // Keyed by date: minecraft:local_time selects (christmas chest items) change with the day. ZonedDateTime now = SpecialDates.now(); return infos.computeIfAbsent(id + "#" + context + "#" + now.toLocalDate(), k -> { try { return resolve(id, context, now); } catch (RuntimeException e) { - // Unexpected item-definition format (newer version?): fall back to the plain sprite. - return new Info(null, List.of(), List.of(), null, null, null); + // Unexpected item-definition format (newer version?): the plain sprite. + return EMPTY; } }); } @@ -206,13 +190,7 @@ public final class ItemGeometry { JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null; special = sm == null ? null : str(sm, "type").replace("minecraft:", ""); specialModel = sm; - // Like ItemModel.Unbaked.bake: the transformations of the enclosing select/condition nodes - // compose with the special's own (shield/trident flips, copper golem statue placement). specialXf = outer[0]; - if (node.has("transformation")) { - Affine own = transformation(node.getAsJsonObject("transformation")); - specialXf = specialXf == null ? own : specialXf.mul(own); - } } else { modelRef = str(node, "model"); } @@ -223,7 +201,7 @@ public final class ItemGeometry { } if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id; - // Parent chain: nearest "display" entry for the context wins; layer textures; generated flag. + // Parent chain: the nearest "display" entry for the context wins. double[] display = null; Map tex = new HashMap<>(); boolean generated = false; @@ -261,13 +239,14 @@ public final class ItemGeometry { } /** - * Walks an item-model node to the concrete model/special for a display context; {@code outer[0]} - * accumulates the {@code transformation}s of the nodes passed on the way (null = none). + * Walks an item-model node to the model/special for a display context. {@code outer[0]} accumulates the + * {@code transformation}s of the nodes passed down to a special (ItemModel.Unbaked.bake: shield/trident flips, + * statue placement; null = none). */ private static JsonObject pick(JsonObject node, String context, ZonedDateTime now, Affine[] outer) { for (int depth = 0; node != null && depth < 16; depth++) { String type = str(node, "type").replace("minecraft:", ""); - if (!type.equals("model") && !type.equals("special") && node.has("transformation")) { + if (!type.equals("model") && node.has("transformation")) { Affine t = transformation(node.getAsJsonObject("transformation")); outer[0] = outer[0] == null ? t : outer[0].mul(t); } @@ -316,10 +295,7 @@ public final class ItemGeometry { return null; } - /** - * The {@code minecraft:local_time} select value (LocalTime property: SimpleDateFormat {@code pattern} with - * optional {@code locale} and {@code time_zone}) for {@code now}; null when the format is invalid. - */ + /** The {@code minecraft:local_time} select value (SimpleDateFormat) for {@code now}; null when invalid. */ private static String localTime(JsonObject node, ZonedDateTime now) { try { String locale = str(node, "locale"); @@ -416,9 +392,6 @@ public final class ItemGeometry { Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m; switch (info.special) { case "shield" -> { - // ShieldSpecialRenderer: shield_base_nopattern, or (banner patterns / base colour present) - // shield_base + the dyed shield pattern masks (base colour default white); the item definition's - // transformation flips the model (1,-1,-1). CemModelLoader.CemModel model = models.vanilla("layer/shield#main"); int[][] tex = shieldTexture(item.blockEntity()); if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); @@ -426,36 +399,25 @@ public final class ItemGeometry { case "trident" -> { CemModelLoader.CemModel model = models.vanilla("trident"); if (model == null) model = models.vanilla("layer/trident#main"); - int[][] tex = textures.get(ResourceLocation.parse("entity/trident/trident")) - .orElse(null); + int[][] tex = texture("entity/trident/trident"); if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); } case "chest" -> { - // ChestSpecialRenderer: the chest model of the item's chest_type with the item definition's - // texture (Sheets.CHEST_MAPPER: entity/chest/; christmas via the local_time select). - if (!bakeChest(info.specialModel, xf, out) && item.blockEntity() != null && blockEntities != null) { - blockEntities.bakeModel(item.blockEntity(), xf, out); - } - } - default -> { - // Block-entity specials (head, banner, shulker box, conduit, pot, …): like the vanilla special - // renderers, the bare model (no block pose) placed by the item's transformation. - if (item.blockEntity() != null && blockEntities != null) { - blockEntities.bakeModel(item.blockEntity(), xf, out); - } + if (!bakeChest(info.specialModel, xf, out)) bakeBlockEntityModel(item.blockEntity(), xf, out); } + // Block-entity specials (head, banner, shulker box, conduit, pot, …): the bare model. + default -> bakeBlockEntityModel(item.blockEntity(), xf, out); } } - /** An entity model (Java model space, px) through {@code xf}. */ private static void bakeEntityModel(CemModelLoader.CemModel model, int[][] tex, Affine xf, List out) { - Affine pre = xf.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); - for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) { - out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world())); - } + CemGeometry.bakeCubes(model, tex, CemGeometry.modelSpace(xf), Set.of(), out); } - /** The chest special model from its JSON ({@code texture}, {@code chest_type}); false when unresolvable. */ + /** + * ChestSpecialRenderer: the chest model of the {@code chest_type} with the definition's texture + * ({@code entity/chest/}); false when unresolvable. + */ private boolean bakeChest(JsonObject sm, Affine xf, List out) { if (sm == null || str(sm, "texture").isEmpty()) return false; String layer = @@ -466,27 +428,27 @@ public final class ItemGeometry { }; CemModelLoader.CemModel model = models.vanilla(layer); ResourceLocation id = ResourceLocation.parse(str(sm, "texture")); - int[][] tex = textures.get(ResourceLocation.parse(id.namespace() + ":entity/chest/" + id.path())) - .orElse(null); + int[][] tex = texture(id.namespace() + ":entity/chest/" + id.path()); if (model == null || tex == null) return false; bakeEntityModel(model, tex, xf, out); return true; } - /** The shield texture for the stack's patterns ({@code BANNER} state from the item meta; null = none). */ + /** + * ShieldSpecialRenderer: shield_base_nopattern, or with patterns/base colour (a {@code BANNER} state from the + * item meta) shield_base + the dyed shield masks (base colour default white). + */ private int[][] shieldTexture(BlockEntityState patterns) { boolean patterned = patterns != null && (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty()); - int[][] base = textures.get(ResourceLocation.parse( - patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern")) - .orElse(null); + int[][] base = texture(patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern"); if (!patterned || base == null || blockEntities == null) return base; int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0); return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns()); } private void bakeGenerated(Info info, Affine m, List out) { - // Cached per Info (one per item+context): a stable texture identity keeps the extrusion cache bounded. + // Cached per Info: a stable texture identity keeps the extrusion cache from growing per bake. int[][] composed = composedSprites .computeIfAbsent(info, k -> Optional.ofNullable(compose(k))) .orElse(null); @@ -497,36 +459,30 @@ public final class ItemGeometry { private int[][] compose(Info info) { int[][] composed = null; for (int i = 0; i < info.layers.size(); i++) { - int[][] t = textures.get(ResourceLocation.parse(info.layers.get(i))).orElse(null); + int[][] t = texture(info.layers.get(i)); if (t == null) continue; int tint = i < info.tints.size() ? info.tints.get(i) : 0; if (composed == null) { - composed = TextureOps.deepCopy(t); - if (tint != 0) TextureOps.tint(composed, tint); + composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t); } else if (t.length == composed.length && t[0].length == composed[0].length) { - int[][] layer = t; - if (tint != 0) { - layer = TextureOps.deepCopy(t); - TextureOps.tint(layer, tint); - } - TextureOps.overlay(composed, layer); + TextureOps.overlay(composed, tint != 0 ? TextureOps.tinted(t, tint) : t); } } return composed; } private int[][] sprite(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); + int[][] t = texture("item/" + id); + return t != null ? t : texture("block/" + id); + } + + private int[][] texture(String path) { + return textures.get(ResourceLocation.parse(path)).orElse(null); } private record Box(double[] from, double[] to, Face[] faces) {} - /** - * Vanilla-style extruded sprite in the 0..1 cube, 1px thick at z 7.5..8.5 px: one box per horizontal run - * of opaque texels; front/back show the texture, the edges the run's texel colours. - */ + /** Extruded sprite: one box per horizontal run of opaque texels; edges show the run's end texels. */ private void addExtruded(int[][] tex, Affine m, List out) { List boxes; synchronized (extrusions) { @@ -538,7 +494,6 @@ public final class ItemGeometry { private static List extrude(int[][] tex) { int h = tex.length, w = h == 0 ? 0 : tex[0].length; List boxes = new ArrayList<>(); - double z0 = 7.5 / 16, z1 = 8.5 / 16; for (int py = 0; py < h; py++) { for (int px = 0; px < w; ) { if (!ColorUtil.opaque(tex[py][px])) { @@ -557,7 +512,7 @@ public final class ItemGeometry { double uw = (start + 0.5) / w, ue = (px - 0.5) / w; f[Direction.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1); f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, vm, 0, -1); - boxes.add(new Box(new double[] {u1, 1 - v2, z0}, new double[] {u2, 1 - v1, z1}, f)); + boxes.add(new Box(new double[] {u1, 1 - v2, Z0}, new double[] {u2, 1 - v1, Z1}, f)); } } return boxes; @@ -574,7 +529,6 @@ public final class ItemGeometry { /** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */ private static Affine elementTransform(Element e) { if (e.isAxisAligned()) return Affine.identity(); - double[] o = e.rotOrigin; Affine r = switch (e.rotAxis) { case 0 -> Affine.rotX(e.rotAngleRad); @@ -587,21 +541,18 @@ public final class ItemGeometry { double[] f = e.rescaleFactors(); r = r.mul(Affine.scale(f[0], f[1], f[2])); } - return Affine.translation(o[0], o[1], o[2]).mul(r).mul(Affine.translation(-o[0], -o[1], -o[2])); + return Affine.rotationAround(r, e.rotOrigin[0], e.rotOrigin[1], e.rotOrigin[2]); } - /** Bakes a biome tint into a copy of the face texture (the entity path has no per-hit tinting). */ + /** The face with its tint baked into a texture copy (the entity path has no per-hit tinting). */ private Face tint(Face f, BlockData data) { if (f == null || f.tintIndex < 0 || data == null) return f; - int color = TintResolver.resolve(data, f.tintIndex, DEFAULT_TINT); + int color = TintResolver.resolve(data, f.tintIndex, BiomeTint.PLAINS); if (color == -1) return f; int[][] t; synchronized (tinted) { - t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()).computeIfAbsent(color, c -> { - int[][] copy = TextureOps.deepCopy(f.texture); - TextureOps.tint(copy, c); - return copy; - }); + t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()) + .computeIfAbsent(color, c -> TextureOps.tinted(f.texture, c)); } return f.withTexture(t, -1); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java index f11b5d3..7f7394b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/SignTextRasterizer.java @@ -5,27 +5,26 @@ import eu.mhsl.minecraft.pixelpics.assets.font.Glyph; import java.util.List; /** - * Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at - * the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas - * top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays - * out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the - * text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common - * baseline. Glowing text gets an 8-directional outline. + * Rasterizes text lines into a transparent ARGB grid (top-left origin) at the font's native pixel size: lines + * centred horizontally, blank lines keep their slot, glyphs of all providers on a common baseline, glowing text + * with an 8-directional outline. */ public final class SignTextRasterizer { private SignTextRasterizer() {} - /** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */ + /** The text panel, or null when nothing is drawn (no font/glyphs). */ public static int[][] rasterize(List lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) { if (font.isEmpty() || lines.isEmpty()) return null; int ascent = font.maxAscent(); - int pitch = font.lineHeight(); // ascent + descent + int pitch = font.lineHeight(); int margin = glow ? 1 : 0; // room for the outline + int[] widths = new int[lines.size()]; int contentW = 1; - for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font)); + for (int i = 0; i < widths.length; i++) + contentW = Math.max(contentW, widths[i] = lineWidth(lines.get(i), font)); int contentH = Math.max(1, lines.size() * pitch); int w = contentW + 2 * margin; @@ -33,25 +32,21 @@ public final class SignTextRasterizer { int[][] out = new int[h][w]; boolean[][] covered = new boolean[h][w]; - // Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`. boolean any = false; for (int li = 0; li < lines.size(); li++) { String line = lines.get(li); - int penX = margin + (contentW - lineWidth(line, font)) / 2; + int penX = margin + (contentW - widths[li]) / 2; int baseline = margin + li * pitch + ascent; for (int ci = 0; ci < line.length(); ) { int cp = line.codePointAt(ci); ci += Character.charCount(cp); Glyph g = font.glyph(cp); - if (g != null) { - any |= blit(g, penX, baseline, fillArgb, out, covered); - } + if (g != null) any |= blit(g, penX, baseline, fillArgb, out, covered); penX += font.advance(cp); } } if (!any) return null; - // Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels. if (glow) { for (int y = 0; y < h; y++) { for (int x = 0; x < w; x++) { @@ -70,7 +65,7 @@ public final class SignTextRasterizer { return out; } - /** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */ + /** Stamps the glyph's opaque pixels in {@code fillArgb}, its baseline on {@code baseline}; false if none landed. */ private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) { double scale = g.height() / (double) g.cellH(); int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5)); @@ -86,7 +81,7 @@ public final class SignTextRasterizer { if (ox < 0 || ox >= out[0].length) continue; int sx = g.srcX() + (int) (dx / scale); int argb = g.tex()[sy][sx]; - if (((argb >>> 24) & 0xFF) == 0) continue; + if ((argb >>> 24) == 0) continue; out[oy][ox] = fillArgb; covered[oy][ox] = true; any = true; @@ -95,8 +90,7 @@ public final class SignTextRasterizer { return any; } - /** Total advance width of a line in font px. */ - /** Width of a line in font pixels (sum of glyph advances). */ + /** Width of a line in font px (sum of glyph advances). */ public static int lineWidth(String line, BitmapFont font) { int w = 0; for (int ci = 0; ci < line.length(); ) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTint.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTint.java index f54b463..6238802 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTint.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTint.java @@ -3,4 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.tint; /** * The biome-dependent tint colors (RGB) for the colormap-driven channels. */ -public record BiomeTint(int grass, int foliage, int dryFoliage, int water) {} +public record BiomeTint(int grass, int foliage, int dryFoliage, int water) { + /** Plains colours: the fallback without a colormap, and the tint of blocks without biome context. */ + public static final BiomeTint PLAINS = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4); +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java index f20107e..5cc07d8 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java @@ -41,9 +41,9 @@ public final class BiomeTintProvider { private BiomeTint compute(String key) { BiomeClimate.Climate climate = BiomeClimate.forKey(key); - int grass = channel(climate.grass(), grassMap, climate, 0xFF91BD59); - int foliage = channel(climate.foliage(), foliageMap, climate, 0xFF77AB2F); - int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, 0xFFA9A05B); + int grass = channel(climate.grass(), grassMap, climate, BiomeTint.PLAINS.grass()); + int foliage = channel(climate.foliage(), foliageMap, climate, BiomeTint.PLAINS.foliage()); + int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, BiomeTint.PLAINS.dryFoliage()); // Vanilla grass_color_modifier (grass only). if ("swamp".equals(climate.grassModifier())) {