simplify cem entity, block-entity and item code: entity bake split into body texture, vanilla placement and ground snap, part frames only for entities holding items, entity info looked up once, identical toggles-only pose fallback removed, shared model-space and bake-to-cubes helpers, model cube without unread origin/inflate, tinted copy helper, plains tint shared with the biome provider, item bounds via the rendered entity aabb, element rotation via rotationAround, special transformations accumulated while picking, precomputed end portal layer constants and gateway face set, sign text areas as records, dump info parsing split out, unused loader size removed, comments trimmed

This commit is contained in:
MineTec committed 2026-11-05 00:57:17 +01:00
1 parent cbc859b565
commit 7f2520a52d
11 files changed
+592 -765

No files matched your search

@@ -1,23 +1,15 @@
package eu.mhsl.minecraft.pixelpics.render.entity; 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 class ModelCube {
public final double[] origin;
public final double[] size; public final double[] size;
public final double inflate; // grows the box on all sides (overlay layers) public final double[] uv;
public final double[] uv; // box-UV offset (texels)
public final boolean mirror; public final boolean mirror;
// Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV. // Per-face {u, v, w, h} texels by Direction.ordinal() (negative w/h flip); null = box-UV.
public final double[][] faceUv; public final double[][] faceUv;
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror) { public ModelCube(double[] size, double[] uv, boolean mirror, double[][] faceUv) {
this(origin, size, inflate, uv, mirror, null);
}
public ModelCube(double[] origin, double[] size, double inflate, double[] uv, boolean mirror, double[][] faceUv) {
this.origin = origin;
this.size = size; this.size = size;
this.inflate = inflate;
this.uv = uv; this.uv = uv;
this.mirror = mirror; this.mirror = mirror;
this.faceUv = faceUv; this.faceUv = faceUv;
@@ -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. */ /** Src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
public static void overlay(int[][] dst, int[][] src) { public static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length); int h = Math.min(dst.length, src.length);
@@ -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.entity.TextureOps;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; 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 * Bakes a {@link BlockEntityState} into world-space cubes: the dumped models its vanilla BlockEntityRenderer draws
* draws (dumped from the client, see {@link BlockEntityModels#vanillaModel}), placed by that renderer's * ({@link BlockEntityModels#vanillaModel}) at {@code T(cell) · renderer pose}, plus sign text, contents (items,
* own pose — {@code T(cell min corner) · pose · S(1/16) · CEM_TO_JAVA} — so position, rotation and box-UV * spawner mob), beacon beam and end portal faces.
* 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).
*/ */
public final class BlockEntityBaker implements EntityBaker<BlockEntityState> { public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
// Sign text is scaled to fill the board's writable area (W×H, font px of the renderer's text frame) so // Deliberate deviation for legibility on 128 px maps: sign text is scaled to fill the writable area (w×h in
// few-line signs render as large as possible (legibility on low-res maps), capped so a single line isn't // text-frame px), capped so a single line isn't blown up. Vanilla: 1 font px per text px, lines 90 (hanging 60)
// blown up absurdly. Vanilla: 1 font px per text px, lines 90 (hanging 60) px wide, 10 (9) px apart. // px wide, 10 (9) px apart.
private static final double SIGN_TEXT_W = 88, SIGN_TEXT_H = 44, SIGN_TEXT_CAP = 2.0; private record TextArea(double w, double h, double cap) {}
private static final double HANG_TEXT_W = 58, HANG_TEXT_H = 40, HANG_TEXT_CAP = 1.87;
private static final double TEXT_THICK = private static final TextArea SIGN_TEXT = new TextArea(88, 44, 2.0);
0.2; // slab behind the text plane (text px) so the ray test never degenerates 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 CemModelLoader models;
private final TextureCache textures; private final TextureCache textures;
private final SkinCache skins; private final SkinCache skins;
private final BitmapFont font; 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); private final DerivedTextureCache derived = new DerivedTextureCache(256);
// End portal/gateway face textures (per block face, the star field is not periodic) and beam textures. // End portal/gateway faces (one per block face: the star field is not periodic) and beacon beam textures.
private final DerivedTextureCache portals = new DerivedTextureCache(512); private final DerivedTextureCache effects = new DerivedTextureCache(512);
// Item models (campfire/shelf/vault contents) and the mob baker (spawners); wired after construction // Contents bakers (items, spawner mob), wired after construction since both depend on this baker; null = off.
// because both depend on this baker themselves. Null = contents not drawn.
private volatile ItemGeometry items; private volatile ItemGeometry items;
private volatile CemBaker entities; private volatile CemBaker entities;
/** Item geometry for campfire/shelf/vault contents. */
public void setItemGeometry(ItemGeometry items) { public void setItemGeometry(ItemGeometry items) {
this.items = items; this.items = items;
} }
/** Mob baker for the spawner's display entity. */
public void setEntityBaker(CemBaker entities) { public void setEntityBaker(CemBaker entities) {
this.entities = entities; this.entities = entities;
} }
/** The sign/text font (shared with text-display rendering). */ /** The sign font, shared with text displays. */
public BitmapFont font() { public BitmapFont font() {
return font; return font;
} }
@@ -84,35 +84,38 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
return cubes.isEmpty() ? null : RenderedEntity.of(cubes); return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
} }
/** /** The models WITHOUT the block pose, mapped by {@code toWorld} (special item renderers, worn skulls). */
* 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).
*/
public void bakeModel(BlockEntityState s, Affine toWorld, List<EntityCube> out) { public void bakeModel(BlockEntityState s, Affine toWorld, List<EntityCube> out) {
BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s); BlockEntityModels.VanillaModel vm = BlockEntityModels.vanillaModel(s);
if (vm != null) bakeParts(s, vm, toWorld, out); if (vm != null) bakeParts(s, vm, toWorld, out);
} }
private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List<EntityCube> out) { private void bakeParts(BlockEntityState s, BlockEntityModels.VanillaModel vm, Affine pose, List<EntityCube> 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()) { for (BlockEntityModels.Part part : vm.parts()) {
CemModelLoader.CemModel model = models.vanilla(part.model()); CemModelLoader.CemModel model = models.vanilla(part.model());
int[][] tex = model == null ? null : texture(s, part); int[][] tex = model == null ? null : texture(s, part);
if (tex == null) continue; if (tex != null) CemGeometry.bakeCubes(model, tex, pre, part.hidden(), out);
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, part.hidden())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
} }
} }
// --- contents: items, spawner mob, beacon beam, end portal --- // --- contents: items, spawner mob, beacon beam, end portal ---
private void addContents(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private void addContents(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
ItemGeometry ig = items;
switch (s.kind()) { switch (s.kind()) {
case CAMPFIRE -> addCampfireItems(s, cell, cubes); case CAMPFIRE -> {
case SHELF -> addShelfItems(s, cell, cubes); if (ig != null) addCampfireItems(ig, s, cell, cubes);
case VAULT -> addVaultItem(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 SPAWNER -> addSpawnerMob(s, cell, cubes);
case BEACON -> addBeam(s, cell, cubes); case BEACON -> addBeam(s, cell, cubes);
case END_PORTAL, END_GATEWAY -> addPortal(s, cell, cubes); case END_PORTAL, END_GATEWAY -> addPortal(s, cell, cubes);
@@ -129,9 +132,7 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
* T(-0.3125, -0.3125, 0)·S(0.375)}, {@code dir = from2DDataValue((i + facing.get2DDataValue()) % 4)}, in * T(-0.3125, -0.3125, 0)·S(0.375)}, {@code dir = from2DDataValue((i + facing.get2DDataValue()) % 4)}, in
* the {@code fixed} display context. * the {@code fixed} display context.
*/ */
private void addCampfireItems(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private static void addCampfireItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
ItemGeometry ig = items;
if (ig == null) return;
int facing2d = Math.floorMod(Math.round(s.facingDeg() / 90), 4); // S0 W1 N2 E3 = yaw / 90 int facing2d = Math.floorMod(Math.round(s.facingDeg() / 90), 4); // S0 W1 N2 E3 = yaw / 90
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
ItemSpec item = slot(s, i); ItemSpec item = slot(s, i);
@@ -151,9 +152,7 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
* 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 * 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. * bounding box vertically centred.
*/ */
private void addShelfItems(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private static void addShelfItems(ItemGeometry ig, BlockEntityState s, Affine cell, List<EntityCube> cubes) {
ItemGeometry ig = items;
if (ig == null) return;
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
ItemSpec item = slot(s, i); ItemSpec item = slot(s, i);
if (item == null) continue; if (item == null) continue;
@@ -169,18 +168,10 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
} }
} }
/** 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<EntityCube> 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)} * 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 * (spin 0, scale 0.53125 / max(width, height) when that exceeds 1), the mob at the origin (baked at its own
* dispatcher places it at the origin (baked at its own position, moved back by -pos). * position, moved back by -pos).
*/ */
private void addSpawnerMob(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private void addSpawnerMob(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
CemBaker baker = entities; CemBaker baker = entities;
@@ -203,15 +194,15 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
// BeaconRenderer: sections up to MAX_RENDER_Y (the last one), solid core radius 0.2 rotated by // 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; // 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_MAX = 2048;
private static final int BEAM_PIECE = 32; // blocks per cube; textures tiled to cover it 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<EntityCube> cubes) { private void addBeam(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
BlockEntityState.Beam beam = s.beam(); BlockEntityState.Beam beam = s.beam();
if (beam == null || beam.sections().isEmpty()) return; if (beam == null || beam.sections().isEmpty()) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/beacon/beacon_beam")) int[][] tex = texture("entity/beacon/beacon_beam");
.orElse(null);
if (tex == null) return; if (tex == null) return;
double core = 0.2 * beam.radiusScale() / Math.sqrt(2), glow = 0.25 * beam.radiusScale(); double core = 0.2 * beam.radiusScale() / Math.sqrt(2), glow = 0.25 * beam.radiusScale();
int start = 0; int start = 0;
@@ -235,9 +226,7 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static EntityCube beamCube(int[][] tex, double r, int len, double v, Affine at) { 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 side = new Face(tex, 0, v, 1, 0, 0, -1);
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (Direction d : new Direction[] {Direction.NORTH, Direction.SOUTH, Direction.WEST, Direction.EAST}) { for (Direction d : SIDES) faces[d.ordinal()] = side;
faces[d.ordinal()] = side;
}
return new EntityCube(new double[] {-r, 0, -r}, new double[] {r, len, r}, faces, at, false, true); 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<BlockEntityState> {
* with the section colour: the core opaque (BEACON_BEAM_OPAQUE), the glow at alpha 32/255. * 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) { 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 reps = glowLayer ? BEAM_PIECE : BEAM_PIECE * 5 / 2;
int h = tex.length, w = tex[0].length; int h = tex.length, w = tex[0].length;
int[][] out = new int[h * reps][w]; int[][] out = new int[h * reps][w];
@@ -274,43 +263,57 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
}; };
private static final double PORTAL_SPAN = 16; private static final double PORTAL_SPAN = 16;
private static final int PORTAL_RES = 64; // texels per block face edge 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); * 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. * TheEndGatewayRenderer: the full cube's unoccluded faces. Unlit like the end_portal pipeline.
*/ */
private void addPortal(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private void addPortal(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
int[][] sky = int[][] sky = texture("environment/end_sky");
textures.get(ResourceLocation.parse("environment/end_sky")).orElse(null); int[][] stars = texture("entity/end_portal/end_portal");
int[][] stars = textures.get(ResourceLocation.parse("entity/end_portal/end_portal"))
.orElse(null);
if (sky == null || stars == null) return; if (sky == null || stars == null) return;
boolean gateway = s.kind() == BlockEntityState.Kind.END_GATEWAY; boolean gateway = s.kind() == BlockEntityState.Kind.END_GATEWAY;
double y0 = gateway ? 0 : 0.375, y1 = gateway ? 1 : 0.75; double y0 = gateway ? 0 : 0.375, y1 = gateway ? 1 : 0.75;
int layers = gateway ? 16 : 15;
Set<Direction> shown = gateway ? gatewayFaces(s.direction()) : EnumSet.of(Direction.UP, Direction.DOWN);
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (Direction d : Direction.values()) { for (Direction d : shown) {
boolean show = gateway int[][] tex = effects.get(
? s.direction() == null DerivedTextureCache.key("portal", sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1),
|| ("+" + s.direction() + "+") () -> portalFace(sky, stars, layers, d, s.bx(), s.by(), s.bz(), y0, y1));
.contains("+" + d.name().toLowerCase(java.util.Locale.ROOT) + "+") faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
: 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);
} }
cubes.add(new EntityCube(new double[] {0, y0, 0}, new double[] {1, y1, 1}, faces, cell, false, true)); 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<Direction> gatewayFaces(String joined) {
if (joined == null) return EnumSet.allOf(Direction.class);
Set<Direction> 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( private static int[][] portalFace(
int[][] sky, int[][] stars, int layers, Direction d, int bx, int by, int bz, double y0, double y1) { int[][] sky, int[][] stars, int layers, Direction d, int bx, int by, int bz, double y0, double y1) {
int n = PORTAL_RES; int n = PORTAL_RES;
@@ -318,51 +321,31 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
for (int j = 0; j < n; j++) { for (int j = 0; j < n; j++) {
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
double fs = (i + 0.5) / n, ft = (j + 0.5) / n; 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; double a, b;
switch (d) { switch (d) {
case UP, DOWN -> { case UP -> {
a = wx; a = bx + fs;
b = wz; b = bz + ft;
} }
case NORTH, SOUTH -> { case DOWN -> {
a = wx; a = bx + fs;
b = -wy; 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 -> { default -> {
a = wz; a = bz + (1 - fs);
b = -wy; b = -(by + y0 + (1 - ft) * (y1 - y0));
} }
} }
out[j][i] = portalColor(sky, stars, layers, a / PORTAL_SPAN, b / PORTAL_SPAN); out[j][i] = portalColor(sky, stars, layers, a / PORTAL_SPAN, b / PORTAL_SPAN);
@@ -378,13 +361,10 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
double g = ColorUtil.green(skyTexel) / 255.0 * c0[1]; double g = ColorUtil.green(skyTexel) / 255.0 * c0[1];
double b = ColorUtil.blue(skyTexel) / 255.0 * c0[2]; double b = ColorUtil.blue(skyTexel) / 255.0 * c0[2];
for (int i = 0; i < layers; i++) { 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. // 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 sc = LAYER_SCALE[i], cos = LAYER_COS[i], sin = LAYER_SIN[i];
double ang = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
double cos = Math.cos(ang), sin = Math.sin(ang);
double x = sc * (u * cos - v * sin), y = sc * (u * sin + v * cos); 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; y = y * 0.5 + 0.25;
int t = sampleRepeat(stars, x, y); int t = sampleRepeat(stars, x, y);
double[] c = PORTAL_COLORS[i]; double[] c = PORTAL_COLORS[i];
@@ -392,9 +372,11 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
g += ColorUtil.green(t) / 255.0 * c[1]; g += ColorUtil.green(t) / 255.0 * c[1];
b += ColorUtil.blue(t) / 255.0 * c[2]; b += ColorUtil.blue(t) / 255.0 * c[2];
} }
return ColorUtil.argb( return ColorUtil.argb(255, channel(r), channel(g), channel(b));
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)));
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) { private static int sampleRepeat(int[][] tex, double u, double v) {
@@ -405,33 +387,30 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
// --- textures --- // --- textures ---
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
}
private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) { private int[][] texture(BlockEntityState s, BlockEntityModels.Part part) {
if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) { if (part.paint() == BlockEntityModels.Paint.SKIN && s.skinUrl() != null) {
int[][] skin = skins.get(s.skinUrl()).orElse(null); int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin; if (skin != null) return skin;
} }
for (String path : part.textures()) { for (String path : part.textures()) {
int[][] t = textures.get(ResourceLocation.parse(path)).orElse(null); int[][] t = texture(path);
if (t == null) continue; 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; return null;
} }
/** /**
* BannerRenderer flag: {@code banner_base}, then the {@code base} mask dyed with the base colour, then * BannerRenderer.submitPatterns composited into one texture (one geometry, no coplanar layers): {@code base}
* each pattern mask ({@code entity/banner/<key>}) dyed with its colour, composited into one texture * (banner_base / shield_base), the {@code <dir>base} mask dyed with {@code baseColor} (0 = none), then the
* (one geometry, no coplanar layers). * first 16 pattern masks ({@code <dir><key>}) dyed. {@code dir} = {@code entity/banner/} or
*/ * {@code entity/shield/} (ShieldSpecialRenderer). Cached.
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 <dir>base} mask dyed with {@code baseColor} (0 = none), then the first 16 pattern masks
* ({@code <dir><key>}) dyed with their colours. {@code dir} = {@code entity/banner/} or {@code entity/shield/}
* (shields, ShieldSpecialRenderer). Cached per input.
*/ */
public int[][] patterned(int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) { public int[][] patterned(int[][] base, String dir, int baseColor, List<BlockEntityState.BannerPattern> patterns) {
List<BlockEntityState.BannerPattern> pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size()))); List<BlockEntityState.BannerPattern> pats = List.copyOf(patterns.subList(0, Math.min(16, patterns.size())));
@@ -451,46 +430,39 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
} }
private void overlayMask(int[][] out, String path, int argb) { 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; if (mask == null || mask.length != out.length || mask[0].length != out[0].length) return;
int[][] dyed = TextureOps.deepCopy(mask); TextureOps.overlay(out, TextureOps.tinted(mask, argb));
TextureOps.tint(dyed, argb);
TextureOps.overlay(out, dyed);
} }
// --- sign text --- // --- sign text ---
/** /**
* Front and back text as flat quads in the sign renderer's text frame * Front and back text as quads centred in the sign renderer's text frame ({@link BlockEntityModels#signTextFrame})
* ({@link BlockEntityModels#signTextFrame}): the text block is centred on the frame origin and faces * facing +Z, the reader; the +Z (SOUTH) face reads left to right ({@code s = fx}).
* +Z (the reader). The quad's +Z (SOUTH) face is unflipped: viewed from +Z the intersector reads it
* left to right ({@code s = fx}).
*/ */
private void addSignText(BlockEntityState s, Affine cell, List<EntityCube> cubes) { private void addSignText(BlockEntityState s, Affine cell, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return; if (font == null) return;
BlockEntityState.Kind k = s.kind(); BlockEntityState.Kind k = s.kind();
boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN; boolean hanging = k == BlockEntityState.Kind.HANGING_SIGN;
if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return; if (!hanging && k != BlockEntityState.Kind.SIGN && k != BlockEntityState.Kind.WALL_SIGN) return;
addSide(s, s.frontText(), true, hanging, cell, cubes); TextArea area = hanging ? HANGING_TEXT : SIGN_TEXT;
addSide(s, s.backText(), false, hanging, cell, cubes); addSide(s, s.frontText(), true, area, cell, cubes);
addSide(s, s.backText(), false, area, cell, cubes);
} }
private void addSide( private void addSide(
BlockEntityState s, BlockEntityState s,
BlockEntityState.SignText t, BlockEntityState.SignText t,
boolean front, boolean front,
boolean hanging, TextArea area,
Affine cell, Affine cell,
List<EntityCube> cubes) { List<EntityCube> cubes) {
if (t == null) return; if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize( int[][] bmp = SignTextRasterizer.rasterize(
trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing()); trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return; if (bmp == null) return;
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W; double fpm = Math.min(area.cap, Math.min(area.w / bmp[0].length, area.h / bmp.length));
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 w = bmp[0].length * fpm, h = bmp.length * fpm; double w = bmp[0].length * fpm, h = bmp.length * fpm;
double[] px = new double[1]; double[] px = new double[1];
Affine frame = BlockEntityModels.signTextFrame(s, front, px); Affine frame = BlockEntityModels.signTextFrame(s, front, px);
@@ -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.RenderedEntity;
import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps; import eu.mhsl.minecraft.pixelpics.render.entity.TextureOps;
import java.util.ArrayList; 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.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
/** /**
* Bakes an {@link EntityState} into world-space cubes. * 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,
* <p>Primary source: the VANILLA models dumped from the client jar ({@code tools/VanillaModelDump.java}, * default layers, armor wearers, pose variants and variant assets all taken from the dump (no per-type lists).
* "absolute" models). They are posed by vanilla's own {@code setupAnim} (idle, adult, peaceful), carry * Placed like vanilla, feet at the entity position. Types missing from the dump fall back to the CEM template
* vanilla part visibility, real baby models, per-variant models, and the renderer's layer models * set (rest pose, ground-snapped, no layers).
* (outer layers, saddles, body equipment, humanoid armor, elytra). Model choice is generic and
* name-based ({@code <type>}, {@code <type>/baby}, {@code <type>/<…variant…>}, {@code <type>/<Layer>.…});
* 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.
*
* <p>Fallback: the CEM template set (rest geometry only, ground-snapped, no layers) for a type the dump
* lacks.
*/ */
public final class CemBaker implements EntityBaker<EntityState> { public final class CemBaker implements EntityBaker<EntityState> {
// LivingEntityRenderer at yaw 0: rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0) — used when the dump has no // LivingEntityRenderer at yaw 0 (rotY(180)·scale(-1,-1,1)·translate(0,-1.501,0)) for types without a dumped
// captured transform for a type. // transform.
private static final Affine DEFAULT_LIVING_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}); 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<EntityState> {
private final TextureCache textures; private final TextureCache textures;
private final SkinCache skins; private final SkinCache skins;
private final Map<String, List<String>> subModelCache = new ConcurrentHashMap<>(); private final Map<String, List<String>> 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 final DerivedTextureCache derived = new DerivedTextureCache(512);
private volatile ItemGeometry items; // held/head items; null = not drawn private volatile ItemGeometry items; // held/head items; null = not drawn
/** Enables held-item and head-item rendering. */
public void setItemGeometry(ItemGeometry items) { public void setItemGeometry(ItemGeometry items) {
this.items = items; this.items = items;
} }
@@ -69,26 +65,114 @@ public final class CemBaker implements EntityBaker<EntityState> {
this.skins = skins; this.skins = skins;
} }
/** /** The body model (vanilla dump if {@code model.absolute()}), its name, the type its layers live under. */
* 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).
*/
private record Choice(CemModelLoader.CemModel model, String name, String base, boolean babyModel) {} 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 @Override
public RenderedEntity bake(EntityState s) { public RenderedEntity bake(EntityState s) {
Choice ch = chooseModel(s); Choice ch = chooseModel(s);
CemModelLoader.CemModel model = ch == null ? null : ch.model; CemModelLoader.CemModel model = ch == null ? null : ch.model;
boolean vanilla = model != null && model.absolute(); boolean vanilla = model != null && model.absolute();
String base = ch == null ? s.typeKey() : ch.base; 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); int[][] tex = bodyTexture(s, resolveTexture(s, info, babyModel), base, babyModel);
// Same-UV overlays are composited straight into the base texture (no extra geometry -> no Z-fighting). 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<String, Affine> frames = vanilla && items != null && holdsItem(s) ? new HashMap<>() : null;
List<CemGeometry.Baked> body = tex != null
? CemGeometry.bakePosed(model, renderTexture(model, tex), at.pre, pose.parts, frames)
: List.of();
Out out = new Out();
List<EntityCube> 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<EntityCube> 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<CemGeometry.Baked> 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")) { 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) { } else if (s.markings() != null && tex != null) {
String m = "entity/horse/horse_markings_" + s.markings(); // horse coat markings String m = "entity/horse/horse_markings_" + s.markings();
String path = ch != null && ch.babyModel ? m + "_baby" : m; String path = babyModel ? m + "_baby" : m;
int[][] src = tex; int[][] src = tex;
tex = derived.get(DerivedTextureCache.key("markings", src, path), () -> { tex = derived.get(DerivedTextureCache.key("markings", src, path), () -> {
int[][] out = TextureOps.deepCopy(src); int[][] out = TextureOps.deepCopy(src);
@@ -97,135 +181,40 @@ public final class CemBaker implements EntityBaker<EntityState> {
}); });
} }
if (s.collar() != 0 && tex != null && layerModels(base).stream().noneMatch(n -> n.contains("CollarLayer."))) { 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. // WolfCollarLayer draws the parent model (same UV).
tex = withCollar(tex, base, ch != null && ch.babyModel, s.collar()); tex = withCollar(tex, base, babyModel, s.collar());
} }
if (s.typeKey().equals("tropical_fish") && tex != null && s.tint() != 0) { if (s.typeKey().equals("tropical_fish") && tex != null) tex = tinted(tex, s.tint());
tex = tinted(tex, s.tint()); // white base body tinted with the body colour return tex;
}
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<String, Affine> frames = new java.util.HashMap<>();
List<CemGeometry.Baked> 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<CemGeometry.Baked> baked = out.lit;
List<CemGeometry.Baked> glow = out.glow;
List<EntityCube> 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<CemGeometry.Baked> 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<EntityCube> 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);
} }
// --- poses & items ------------------------------------------------------------------------------- // --- 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<String, CemModelLoader.PartPose> parts) { private record Pose(Affine pre, Map<String, CemModelLoader.PartPose> parts) {
List<CemGeometry.Baked> bake(CemModelLoader.CemModel m, int[][] tex) { List<CemGeometry.Baked> bake(CemModelLoader.CemModel m, int[][] tex) {
return CemGeometry.bakePosed(m, tex, pre, parts, null); return CemGeometry.bakePosed(m, tex, pre, parts, null);
} }
} }
/** /** Baked boxes by how they are drawn: shaded, unshaded (NO_CARDINAL_LIGHTING) and additive (energy swirl). */
* 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).
*/
private static final class Out { private static final class Out {
final List<CemGeometry.Baked> lit = new ArrayList<>(); final List<CemGeometry.Baked> lit = new ArrayList<>();
final List<CemGeometry.Baked> unshaded = new ArrayList<>(); final List<CemGeometry.Baked> unshaded = new ArrayList<>();
final List<CemGeometry.Baked> glow = new ArrayList<>(); final List<CemGeometry.Baked> glow = new ArrayList<>();
} }
/** /** A vanilla model drawn the way its render type draws it. */
* 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.
*/
private void add(Out out, Pose pose, CemModelLoader.CemModel m, int[][] tex) { 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))); (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 // Overlay layers drawn on the body mesh itself (eyes, warden/copper golem/creaking emissive, sheep undercoat):
// golem / creaking emissive layers and the sheep undercoat draw the body mesh or subsets of it, no inflation). // vanilla draws them later and the depth test passes on equal depth. Here their boxes grow by this much (model
// Vanilla draws them afterwards and the depth test passes on equal depth, i.e. on top of the coplanar body; here // px) to lie just in front (on coplanar ties the earlier-baked body would win); well above the tracer's layer
// 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.
// epsilon, far below a visible size (on coplanar ties the earlier-baked body would win).
private static final double OVERLAY_INFLATE = 1 / 256.0; 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<CemGeometry.Baked> target, List<CemGeometry.Baked> baked) { private static void addOverlay(List<CemGeometry.Baked> target, List<CemGeometry.Baked> baked) {
for (CemGeometry.Baked b : baked) { for (CemGeometry.Baked b : baked) {
double[] from = b.from().clone(), to = b.to().clone(); double[] from = b.from().clone(), to = b.to().clone();
@@ -244,11 +233,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** /**
* A texture as the model's vanilla render type sees it: texels below the shader's ALPHA_CUTOUT (0.1 → alpha * A texture as the model's vanilla render type sees it: texels below the shader's ALPHA_CUTOUT (0.1 → alpha
* &lt; 26) are discarded; cutout and solid types write the rest opaque (partial alpha in e.g. llama or * &lt; 26) are discarded; cutout and solid types write the rest opaque (partial alpha of e.g. llama or
* tropical-fish pattern textures is NOT blended in vanilla), solid types draw even fully transparent texels; * tropical-fish patterns is NOT blended in vanilla), solid types even fully transparent texels; only blending
* only blending types (entity_translucent: slime skin, vex, allay, player skins, …) keep the alpha for the * types (entity_translucent: slime, vex, allay, player skins, …) keep the alpha. Unknown or additive render
* tracer to blend. Unknown render type (legacy models, old dumps) or additive: unchanged. Cached; returns * type: unchanged. Cached; {@code tex} itself when nothing changes.
* {@code tex} itself when nothing changes.
*/ */
private int[][] renderTexture(CemModelLoader.CemModel m, int[][] tex) { private int[][] renderTexture(CemModelLoader.CemModel m, int[][] tex) {
return renderTexture(m == null ? null : models.renderType(m.renderType()), tex); return renderTexture(m == null ? null : models.renderType(m.renderType()), tex);
@@ -274,35 +262,25 @@ public final class CemBaker implements EntityBaker<EntityState> {
}); });
} }
/** A chosen pose variant: its name, part overrides and (when it changes it) the renderer transform. */ /** A chosen pose variant: part overrides and (when it changes it) the renderer transform. */
private record SelectedPose( private record SelectedPose(Map<String, CemModelLoader.PartPose> parts, CemModelLoader.PoseTransform transform) {
String name, Map<String, CemModelLoader.PartPose> parts, CemModelLoader.PoseTransform transform) { static final SelectedPose NONE = new SelectedPose(Map.of(), null);
static final SelectedPose NONE = new SelectedPose(null, 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<String> 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 * The dumped pose variant (per body model: baby/small models have their own pivots) for the entity's flags:
* {@code setupAnim} / renderer read, dumped per type and per body model (baby/small models have their own * {@code passenger}, {@code item_r}/{@code item_l}, {@code arms}, {@code chest} and the state poses of
* pivots): sitting as a passenger ({@code passenger}), holding items ({@code item_r}/{@code item_l}), * {@link EntityState#pose()} the dump knows (sleeping, crouching, {@code arms_<armPose>}, …). Combinations
* illagers' crossbow hold ({@code arms_*}), armor-stand arms ({@code arms}), chests ({@code chest}) and the * without an own variant equal a simpler pose: state-pose subsets from all to none, each with all other flags,
* state poses of {@link EntityState#pose()} (sleeping, sitting, crouching, lying, swimming, in_water, …; * then without the held-item flags (e.g. zombies' arms ignore held items).
* 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.
*/ */
private SelectedPose selectPose(EntityState s, CemModelLoader.EntityInfo info, Choice ch) { private SelectedPose selectPose(EntityState s, CemModelLoader.EntityInfo info, Choice ch) {
if (info == null) return SelectedPose.NONE; 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 ? null
: ch.name.substring(ch.base.length() + 1); : ch.name.substring(ch.base.length() + 1);
CemModelLoader.PoseSet set = info.poseSet(sub); CemModelLoader.PoseSet set = info.poseSet(sub);
if (set.poses().isEmpty()) return SelectedPose.NONE; 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_<pose>" state flags from the snapshot.
List<String> flags = new ArrayList<>(); List<String> flags = new ArrayList<>();
if (s.rightHand() != null) flags.add("item_r"); if (s.rightHand() != null) flags.add("item_r");
if (s.leftHand() != null) flags.add("item_l"); if (s.leftHand() != null) flags.add("item_l");
@@ -311,7 +289,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (s.chest()) flags.add("chest"); if (s.chest()) flags.add("chest");
List<String> states = new ArrayList<>(); List<String> states = new ArrayList<>();
if (s.pose() != null) { if (s.pose() != null) {
java.util.Set<String> known = knownPoseFlags(set); Set<String> known = knownPoseFlags(set);
for (String f : s.pose().split("\\+")) if (known.contains(f) && !states.contains(f)) states.add(f); for (String f : s.pose().split("\\+")) if (known.contains(f) && !states.contains(f)) states.add(f);
} }
List<List<String>> tries = new ArrayList<>(); List<List<String>> tries = new ArrayList<>();
@@ -322,26 +300,23 @@ public final class CemBaker implements EntityBaker<EntityState> {
List<String> noItems = new ArrayList<>(st); List<String> noItems = new ArrayList<>(st);
flags.stream().filter(f -> !f.startsWith("item_")).forEach(noItems::add); flags.stream().filter(f -> !f.startsWith("item_")).forEach(noItems::add);
tries.add(noItems); tries.add(noItems);
List<String> toggles = new ArrayList<>(st);
flags.stream().filter(PART_TOGGLES::contains).forEach(toggles::add);
tries.add(toggles);
} }
for (List<String> t : tries) { for (List<String> t : tries) {
if (t.isEmpty()) continue; if (t.isEmpty()) continue;
String name = String.join("+", t.stream().sorted().toList()); String name = String.join("+", t.stream().sorted().toList());
Map<String, CemModelLoader.PartPose> p = set.poses().get(name); Map<String, CemModelLoader.PartPose> 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; return SelectedPose.NONE;
} }
/** Every flag that occurs in a pose name of the set (cached per loaded pose map). */ // Every flag occurring in a pose name, per loaded pose map.
private final Map<Map<String, Map<String, CemModelLoader.PartPose>>, java.util.Set<String>> knownFlags = private final Map<Map<String, Map<String, CemModelLoader.PartPose>>, Set<String>> knownFlags =
java.util.Collections.synchronizedMap(new java.util.IdentityHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
private java.util.Set<String> knownPoseFlags(CemModelLoader.PoseSet set) { private Set<String> knownPoseFlags(CemModelLoader.PoseSet set) {
return knownFlags.computeIfAbsent(set.poses(), poses -> { return knownFlags.computeIfAbsent(set.poses(), poses -> {
java.util.Set<String> out = new java.util.HashSet<>(); Set<String> out = new HashSet<>();
for (String n : poses.keySet()) out.addAll(List.of(n.split("\\+"))); for (String n : poses.keySet()) out.addAll(List.of(n.split("\\+")));
return out; return out;
}); });
@@ -389,7 +364,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
any |= Math.abs(delta[j]) > 1e-6; any |= Math.abs(delta[j]) > 1e-6;
} }
if (!any) continue; if (!any) continue;
if (out == null) out = new java.util.HashMap<>(parts); if (out == null) out = new HashMap<>(parts);
for (Map.Entry<String, double[]> pc : b.getValue().parts().entrySet()) { for (Map.Entry<String, double[]> pc : b.getValue().parts().entrySet()) {
double[] c = pc.getValue(); double[] c = pc.getValue();
double[] add = new double[3]; double[] add = new double[3];
@@ -405,15 +380,11 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
/** /**
* Held items (vanilla ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), babies * Held items (ItemInHandLayer: hand frame · rotX(-90) · rotY(180) · T(±1/16, 0.125, -0.625), third-person
* T(0, 1/16, -4.5/16), * context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · rotX(180), ground context; illagers
* third-person display context), crossed-arms items (villagers/witches: root · T(0, 0.4, -0.4) · * hide held items while their arms are crossed) and head items (CustomHeadLayer).
* 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).
*/ */
private void addItems(EntityState s, Map<String, Affine> frames, boolean illager, List<EntityCube> out) { private void addItems(EntityState s, Map<String, Affine> frames, boolean illager, List<EntityCube> 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; String d = CemGeometry.DRAWN;
boolean crossed = frames.containsKey(d + "arms") && !frames.containsKey(d + "right_arm") && !illager; boolean crossed = frames.containsKey(d + "arms") && !frames.containsKey(d + "right_arm") && !illager;
for (int side = 0; side < 2; side++) { for (int side = 0; side < 2; side++) {
@@ -422,7 +393,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (item == null) continue; if (item == null) continue;
Affine arm = frames.get(left ? "left_arm" : "right_arm"); Affine arm = frames.get(left ? "left_arm" : "right_arm");
if (arm != null && !crossed && !(illager && !frames.containsKey(d + "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"); boolean babyOffset = s.baby() && !s.typeKey().equals("armor_stand");
Affine x = arm.mul(CemGeometry.JAVA_LOCAL) Affine x = arm.mul(CemGeometry.JAVA_LOCAL)
.mul(Affine.rotX(Math.toRadians(-90))) .mul(Affine.rotX(Math.toRadians(-90)))
@@ -446,10 +417,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (head != null && h != null) { if (head != null && h != null) {
Affine hj = h.mul(CemGeometry.JAVA_LOCAL); Affine hj = h.mul(CemGeometry.JAVA_LOCAL);
if (head.blockEntity() != null && head.blockEntity().kind() == BlockEntityState.Kind.HEAD) { 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); items.bakeBlockEntityModel(head.blockEntity(), hj.mul(Affine.scale(1.1875)), out);
} else { } 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; double dy = s.typeKey().equals("snow_golem") ? -0.34375 : -0.25;
Affine x = hj.mul(Affine.translation(0, dy, 0)) Affine x = hj.mul(Affine.translation(0, dy, 0))
.mul(Affine.rotY(Math.PI)) .mul(Affine.rotY(Math.PI))
@@ -460,12 +431,9 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
/** /**
* Item carried in the mouth, rendered in the "ground" context like vanilla (HoldingEntityRenderState). * Mouth item, "ground" context. FoxHeldItemLayer: head PIVOT (the layer turns by the head-look angles, not the
* Fox (FoxHeldItemLayer): head PIVOT (the layer turns by the head-look angles, not the posed head part: * posed head part; PixelPics has no head look) · [baby S(0.75)] · offsets per sleeping/baby. DolphinCarrying-
* PixelPics has no head look) · [baby: S(0.75)] · T(0.06, 0.27 [baby 0.26], -0.5) · rotX(90); asleep * ItemLayer: model root · offset by a = |pitch| / 60, up or down.
* 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.
*/ */
private void addMouthItem(EntityState s, Map<String, Affine> frames, List<EntityCube> out) { private void addMouthItem(EntityState s, Map<String, Affine> frames, List<EntityCube> out) {
ItemSpec item = s.mouthItem(); ItemSpec item = s.mouthItem();
@@ -494,13 +462,10 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (x != null) items.bake(item, "ground", false, x, out); if (x != null) items.bake(item, "ground", false, x, out);
} }
/** /** ParrotOnShoulderLayer: the parrot in its on_shoulder pose at T(±0.4, -1.5 [crouching -1.3], 0). */
* 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.
*/
private void addShoulderParrots(EntityState s, Choice ch, Affine t0, Out out) { private void addShoulderParrots(EntityState s, Choice ch, Affine t0, Out out) {
if (t0 == null || (s.shoulderLeft() == null && s.shoulderRight() == null)) return; if (s.shoulderLeft() == null && s.shoulderRight() == null) return;
if (ch == null || !ch.base.startsWith("player")) return; if (!ch.base.startsWith("player")) return;
CemModelLoader.CemModel pm = models.vanilla("parrot"); CemModelLoader.CemModel pm = models.vanilla("parrot");
CemModelLoader.EntityInfo pi = models.entityInfo("parrot"); CemModelLoader.EntityInfo pi = models.entityInfo("parrot");
if (pm == null || pi == null) return; if (pm == null || pi == null) return;
@@ -516,9 +481,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
if (tex == null && pi.texture() != null) tex = texture(pi.texture()); if (tex == null && pi.texture() != null) tex = texture(pi.texture());
if (tex == null) continue; if (tex == null) continue;
Affine pre = t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0)) Affine pre = CemGeometry.modelSpace(t0.mul(Affine.translation(side == 0 ? 0.4 : -0.4, y, 0)));
.mul(Affine.scale(1 / 16.0))
.mul(CemGeometry.CEM_TO_JAVA);
out.lit.addAll(CemGeometry.bakePosed(pm, renderTexture(pm, tex), pre, parts, null)); out.lit.addAll(CemGeometry.bakePosed(pm, renderTexture(pm, tex), pre, parts, null));
} }
} }
@@ -530,21 +493,20 @@ public final class CemBaker implements EntityBaker<EntityState> {
// --- model choice -------------------------------------------------------------------------------- // --- model choice --------------------------------------------------------------------------------
/** /**
* Picks the body model: a baby model for babies when one exists, a variant-specific model (cow/pig/ * The body model: baby model for babies, variant model matched by name token (temperature variants, salmon
* chicken temperature models, salmon size, pufferfish puff state, …) matched by name token, else the * size, puff state, …), else the main model; else the legacy CEM template.
* type's main model; falls back to the legacy CEM template mapping.
*/ */
private Choice chooseModel(EntityState s) { private Choice chooseModel(EntityState s) {
String type = s.typeKey(); String type = s.typeKey();
if (s.player()) type = s.slim() && models.hasVanilla("player_slim") ? "player_slim" : "player"; if (s.player()) type = s.slim() && models.hasVanilla("player_slim") ? "player_slim" : "player";
String legacy = EntityModels.cemModel(type); 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; String base = models.hasVanilla(type) || !subModels(type).isEmpty() ? type : legacy;
if (base.equals("player") && s.slim() && models.hasVanilla("player_slim")) base = "player_slim"; if (base.equals("player") && s.slim() && models.hasVanilla("player_slim")) base = "player_slim";
String variant = s.variant() == null ? null : s.variant().toLowerCase(Locale.ROOT); String variant = s.variant() == null ? null : s.variant().toLowerCase(Locale.ROOT);
CemModelLoader.VariantAssets va = models.variant(type, s.variant()); 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) { if (type.equals("tropical_fish") && variant != null) {
variant = EntityModels.tropicalPattern(variant) >= 6 ? "large" : "small"; // pattern -> body shape variant = EntityModels.tropicalPattern(variant) >= 6 ? "large" : "small"; // pattern -> body shape
} }
@@ -559,7 +521,6 @@ public final class CemBaker implements EntityBaker<EntityState> {
String n = findSub(base, variant, false); String n = findSub(base, variant, false);
if (n != null) return vanilla(n, base, false); if (n != null) return vanilla(n, base, false);
if (models.hasVanilla(base)) return vanilla(base, 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); CemModelLoader.CemModel lm = models.get(legacy);
return lm == null ? null : new Choice(lm, legacy, base, false); return lm == null ? null : new Choice(lm, legacy, base, false);
} }
@@ -606,7 +567,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
return namesCached("E:" + base, n -> n.startsWith(base + "/equip.")); return namesCached("E:" + base, n -> n.startsWith(base + "/equip."));
} }
private List<String> namesCached(String key, java.util.function.Predicate<String> filter) { private List<String> namesCached(String key, Predicate<String> filter) {
return subModelCache.computeIfAbsent(key, k -> { return subModelCache.computeIfAbsent(key, k -> {
List<String> out = new ArrayList<>(); List<String> out = new ArrayList<>();
for (String n : models.vanillaNames()) if (filter.test(n)) out.add(n); for (String n : models.vanillaNames()) if (filter.test(n)) out.add(n);
@@ -629,65 +590,56 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (lm == null) continue; if (lm == null) continue;
switch (layerClass) { switch (layerClass) {
case "SheepWoolLayer", "SheepWoolUndercoatLayer" -> { case "SheepWoolLayer", "SheepWoolUndercoatLayer" -> {
// Wool: unless sheared. Undercoat (SheepWoolUndercoatLayer): only adults (no baby model) with // Undercoat: only adults with non-white or jeb_ wool (snapshot flag), sheared or not.
// non-white or jeb_ wool ("undercoat" flag from the snapshot) — sheared or not.
boolean undercoat = layerClass.equals("SheepWoolUndercoatLayer"); boolean undercoat = layerClass.equals("SheepWoolUndercoatLayer");
if (undercoat ? !s.hasPose("undercoat") : "sheared".equals(s.variant())) continue; if (undercoat ? !s.hasPose("undercoat") : "sheared".equals(s.variant())) continue;
if (lm.texture() == null) continue; int[][] t = layerTexture(lm);
int[][] t = texture(lm.texture());
if (t == null) continue; if (t == null) continue;
t = tinted(t, s.tint());
if (undercoat || baby) { if (undercoat || baby) {
// Undercoat and baby wool have the body's mesh (no inflation; coloredCutoutModelCopyLayerRender // The body's own mesh (coloredCutoutModelCopyLayerRender): an overlay on its surface.
// draws them on top): an overlay on its surface. addOverlay(out.lit, pose.bake(lm, renderTexture(lm, t)));
addOverlay(out.lit, pose.bake(lm, renderTexture(lm, tinted(t, s.tint()))));
} else { } else {
add(out, pose, lm, tinted(t, s.tint())); add(out, pose, lm, t);
} }
} }
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, out); case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, out);
case "CatCollarLayer" -> { case "CatCollarLayer" -> {
// Own collar model (inflated by 0.01), collar texture tinted with the collar's dye colour. int[][] t = s.collar() == 0 ? null : layerTexture(lm);
if (s.collar() == 0 || lm.texture() == null) continue; if (t != null) add(out, pose, lm, tinted(t, s.collar()));
int[][] t = texture(lm.texture());
if (t == null) continue;
add(out, pose, lm, tinted(t, s.collar()));
} }
case "CreeperPowerLayer", "WitherArmorLayer" -> { case "CreeperPowerLayer", "WitherArmorLayer" -> {
// EnergySwirlLayer: charged creeper / wither below half health; drawn additively. // EnergySwirlLayer, drawn additively.
if (!s.powered() || lm.texture() == null) continue; if (!s.powered() || lm.texture() == null) continue;
int[][] t = energySwirl(lm.texture(), layerClass.equals("WitherArmorLayer")); int[][] t = energySwirl(lm.texture(), layerClass.equals("WitherArmorLayer"));
if (t != null) out.glow.addAll(pose.bake(lm, t)); if (t != null) out.glow.addAll(pose.bake(lm, t));
} }
case "SlimeOuterLayer" -> { 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; int[][] t = lm.texture() != null ? texture(lm.texture()) : bodyTex;
if (t != null) add(out, pose, lm, t); if (t != null) add(out, pose, lm, t);
} }
case "LlamaDecorLayer", "WolfArmorLayer" -> { case "LlamaDecorLayer", "WolfArmorLayer" -> {
// Body equipment drawn by a dedicated layer: texture folder <base>_body (trader llama: llama_body). // Texture folder <base>_body (trader llama: llama_body).
if (s.bodyEquip() == null) continue; if (s.bodyEquip() == null) continue;
int[][] t = equipmentTexture(base + "_body", s.bodyEquip(), baby, s.bodyDye()); int[][] t = equipmentTexture(base + "_body", s.bodyEquip(), baby, s.bodyDye());
if (t == null) { if (t == null && base.contains("_")) {
t = equipmentTexture( t = equipmentTexture(
base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye()); base.substring(base.indexOf('_') + 1) + "_body", s.bodyEquip(), baby, s.bodyDye());
} }
if (t != null) add(out, pose, lm, t); if (t != null) add(out, pose, lm, t);
} }
case "RopesLayer" -> { case "RopesLayer" -> {
if (s.bodyEquip() == null || lm.texture() == null) continue; // ropes only with a harness int[][] t = s.bodyEquip() == null ? null : layerTexture(lm); // only with a harness
int[][] t = texture(lm.texture());
if (t != null) add(out, pose, lm, t); if (t != null) add(out, pose, lm, t);
} }
default -> { default -> {
// Layers vanilla draws for the default state (drowned/stray/bogged outer, breeze wind, …); // Other layers only if vanilla draws them for the default state (drowned outer, breeze wind, …).
// state-dependent ones (charged, tamed, equipped, …) are handled above or not drawn. int[][] t = info == null || !info.layerDrawnByDefault(layerClass) ? null : layerTexture(lm);
if (info == null || !info.layerDrawnByDefault(layerClass) || lm.texture() == null) continue;
int[][] t = texture(lm.texture());
if (t != null) add(out, pose, lm, t); if (t != null) add(out, pose, lm, t);
} }
} }
} }
// Equipment layers (saddles, body armor/harness/carpets), named <base>/equip.<layerType>.<field>. // Equipment layers <base>/equip.<layerType>.<field> (saddles, body armor, harness, carpets).
for (String n : equipModels(base)) { for (String n : equipModels(base)) {
String rest = n.substring(base.length() + "/equip.".length()); String rest = n.substring(base.length() + "/equip.".length());
String layerType = rest.substring(0, rest.indexOf('.')); String layerType = rest.substring(0, rest.indexOf('.'));
@@ -705,18 +657,23 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
} }
/** 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) { private void addTropicalPattern(EntityState s, String name, CemModelLoader.CemModel lm, Pose pose, Out out) {
int idx = EntityModels.tropicalPattern(s.variant()); int idx = EntityModels.tropicalPattern(s.variant());
if (idx < 0) return; 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; if (name.toLowerCase(Locale.ROOT).contains("small") != small) return;
int[][] t = texture("entity/fish/tropical_" + (small ? "a" : "b") + "_pattern_" + (idx % 6 + 1)); int[][] t = texture("entity/fish/tropical_" + (small ? "a" : "b") + "_pattern_" + (idx % 6 + 1));
if (t == null) return; if (t == null) return;
add(out, pose, lm, tinted(t, s.tint2())); add(out, pose, lm, tinted(t, s.tint2()));
} }
/** entity/equipment/<layerType>/<asset> (+ a "_carpet"-stripped name; leather dye + overlay); cached. */ /** {@code entity/equipment/<layerType>/<asset>} (baby, "_carpet"-stripped fallbacks; dye + overlay); cached. */
private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) { private int[][] equipmentTexture(String layerType, String asset, boolean baby, int dye) {
return derived.get( return derived.get(
DerivedTextureCache.key("equip", layerType, asset, baby, dye), DerivedTextureCache.key("equip", layerType, asset, baby, dye),
@@ -748,16 +705,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
// --- energy swirl & collars ---------------------------------------------------------------------- // --- energy swirl & collars ----------------------------------------------------------------------
// A photo has no time: the swirl's scrolling texture is frozen at this age (ticks). Chosen so the sparse // A photo has no time: the swirl scroll is frozen at this age (ticks), chosen so the sparse wither_armor pattern
// wither_armor pattern covers every wither part (at e.g. 42 the side heads land on empty rows). // covers every wither part (at e.g. 42 the side heads land on empty rows).
private static final float SWIRL_AGE = 59f; private static final float SWIRL_AGE = 59f;
/** /**
* Energy-swirl texture (vanilla EnergySwirlLayer / RenderTypes.energySwirl): the texture matrix shifts the UV * EnergySwirlLayer texture: the texture matrix shifts the UV by ({@code xOffset(age) % 1}, {@code age·0.01 % 1})
* by ({@code xOffset(age) % 1}, {@code age·0.01 % 1}) on a repeating texture — baked here as a wrapped copy * (xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}), baked as a rolled copy; vertex colour
* of the texture rolled by that offset — and the vertex colour 0xFF808080 halves the RGB. The result is * 0xFF808080 halves the RGB. Drawn additively, so alpha only cuts out.
* 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}.
*/ */
private int[][] energySwirl(String path, boolean wither) { private int[][] energySwirl(String path, boolean wither) {
return derived.get(DerivedTextureCache.key("swirl", path, wither), () -> buildEnergySwirl(path, wither)); return derived.get(DerivedTextureCache.key("swirl", path, wither), () -> buildEnergySwirl(path, wither));
@@ -781,7 +736,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out; return out;
} }
/** Body texture with the dye-tinted {@code entity/<base>/<base>_collar[_baby]} composited over it. */ /** Body texture with the dyed {@code entity/<base>/<base>_collar[_baby]} over it. */
private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) { private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) {
return derived.get( return derived.get(
DerivedTextureCache.key("collar", tex, base, baby, dye), () -> buildWithCollar(tex, base, baby, dye)); DerivedTextureCache.key("collar", tex, base, baby, dye), () -> buildWithCollar(tex, base, baby, dye));
@@ -799,30 +754,23 @@ public final class CemBaker implements EntityBaker<EntityState> {
/** {@code src} multiplied by an ARGB tint (cached; {@code src} itself for no tint). */ /** {@code src} multiplied by an ARGB tint (cached; {@code src} itself for no tint). */
private int[][] tinted(int[][] src, int argb) { private int[][] tinted(int[][] src, int argb) {
if (argb == 0) return src; if (argb == 0) return src;
return derived.get(DerivedTextureCache.key("tint", src, argb), () -> { return derived.get(DerivedTextureCache.key("tint", src, argb), () -> TextureOps.tinted(src, argb));
int[][] out = TextureOps.deepCopy(src);
TextureOps.tint(out, argb);
return out;
});
} }
// --- emissive layers ----------------------------------------------------------------------------- // --- emissive layers -----------------------------------------------------------------------------
/** /**
* Emissive layers of the dump ({@link CemModelLoader.EmissiveLayer}: spider / enderman / phantom / breeze / * The dump's emissive layers (eyes, warden bioluminescence, …): texture per variant, processed by the layer's
* copper-golem / creaking eyes, warden bioluminescence): the layer's texture (per variant, e.g. copper-golem * render type, times its static alpha. Skipped: animated layers (a photo has no age), layers whose state flag is
* weathering) on its model, processed by its render type (eyes: blended and unshaded; translucent emissive: * off (creaking eyes), and on invisible entities those vanilla hides.
* 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).
*/ */
private void addEmissiveLayers(EntityState s, Choice ch, CemModelLoader.EntityInfo info, Pose pose, Out out) { 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()) { for (CemModelLoader.EmissiveLayer e : info.emissive()) {
if (e.animated() || e.alpha() <= 1e-5) continue; if (e.animated() || e.alpha() <= 1e-5) continue;
if (e.when() != null && !s.hasPose(e.when())) continue; if (e.when() != null && !s.hasPose(e.when())) continue;
if (s.invisible() && !e.invisible()) 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()); CemModelLoader.CemModel m = e.model().equals(ch.base) ? ch.model : models.vanilla(e.model());
if (m == null) continue; if (m == null) continue;
String variantTex = String variantTex =
@@ -832,7 +780,6 @@ public final class CemBaker implements EntityBaker<EntityState> {
CemModelLoader.RenderType rt = models.renderType(e.renderType()); CemModelLoader.RenderType rt = models.renderType(e.renderType());
t = renderTexture(rt, t); t = renderTexture(rt, t);
if (e.alpha() < 1) t = withAlpha(t, e.alpha()); 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)); addOverlay(rt != null && !rt.shaded() ? out.unshaded : out.lit, pose.bake(m, t));
} }
} }
@@ -858,22 +805,17 @@ public final class CemBaker implements EntityBaker<EntityState> {
} }
/** /**
* Player skin, else a render-state texture of the dump ({@code stateTextures}: tipped arrow, invulnerable * Player skin, else a dumped state texture whose flag is set (tipped arrow, invulnerable wither), else the
* wither) whose flag the entity has, else the data-driven variant's asset (baby / tame / angry aware), else the * registry variant's asset, else the {@link EntityModels} candidates with the renderer's default texture (baby
* variant-aware candidates * model: the {@code _baby} counterparts first).
* ({@link EntityModels}), the vanilla renderer's default texture as a generic fallback, and — for a
* dedicated 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()) { if (s.player()) {
int[][] skin = skins.get(s.skinUrl()).orElse(null); int[][] skin = skins.get(s.skinUrl()).orElse(null);
if (skin != null) return skin; if (skin != null) return skin;
int[][] def = texture(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve"); int[][] def = texture(s.slim() ? "entity/player/slim/steve" : "entity/player/wide/steve");
if (def != null) return def; 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) { if (info != null && s.pose() != null) {
for (Map.Entry<String, String> st : info.stateTextures().entrySet()) { for (Map.Entry<String, String> st : info.stateTextures().entrySet()) {
if (!s.hasPose(st.getKey())) continue; if (!s.hasPose(st.getKey())) continue;
@@ -881,7 +823,6 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (t != null) return t; 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()); CemModelLoader.VariantAssets va = models.variant(s.typeKey(), s.variant());
String asset = va == null ? null : va.texture(babyModel, s.collar() != 0, s.hasPose("angry")); String asset = va == null ? null : va.texture(babyModel, s.collar() != 0, s.hasPose("angry"));
if (asset != null) { if (asset != null) {
@@ -908,12 +849,12 @@ public final class CemBaker implements EntityBaker<EntityState> {
return null; 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 static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"};
private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) { private int[][] compositeVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
if (base == null) return null; 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(); String prof = babyModel ? null : s.profession();
int level = babyModel ? 0 : s.villagerLevel(); int level = babyModel ? 0 : s.villagerLevel();
return derived.get( return derived.get(
@@ -924,7 +865,6 @@ public final class CemBaker implements EntityBaker<EntityState> {
private int[][] buildVillager(EntityState s, int[][] base, String folder, boolean babyModel) { private int[][] buildVillager(EntityState s, int[][] base, String folder, boolean babyModel) {
int[][] out = TextureOps.deepCopy(base); int[][] out = TextureOps.deepCopy(base);
if (babyModel) { if (babyModel) {
// Baby villagers: their own biome-type clothing set; no profession.
if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/baby/" + s.variant()); if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/baby/" + s.variant());
return out; return out;
} }
@@ -940,26 +880,24 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out; 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) { private void overlayIfPresent(int[][] dst, String path) {
int[][] o = texture(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); TextureOps.overlay(dst, o);
} }
// --- humanoid armor --------------------------------------------------------------------------- // --- humanoid armor: the wearer's per-slot models <base>/HumanoidArmorLayer.modelSet.<slot>, posed like the body
// Per-slot armor models of the wearer's renderer (<base>/HumanoidArmorLayer.modelSet.<slot>), posed
// like the body — raised zombie arms included.
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Choice ch, Pose pose, Out out) { private void addArmorLayers(EntityState s, Choice ch, Pose pose, Out out) {
EntityState.Equipment eq = s.equipment(); EntityState.Equipment eq = s.equipment();
String base = ch == null ? s.typeKey() : ch.base; String base = ch.base;
boolean baby = ch != null && ch.babyModel; boolean baby = ch.babyModel;
String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + "."; String set = base + "/HumanoidArmorLayer." + (baby ? "babyModelSet" : "modelSet") + ".";
// HumanoidArmorLayer: babies use the humanoid_baby equipment textures — except small armor stands, whose // HumanoidArmorLayer: babies use humanoid_baby textures, except small armor stands (ARMOR_STAND_SMALL_ARMOR =
// armor models are the adult ones scaled (ARMOR_STAND_SMALL_ARMOR = BABY_TRANSFORMER over the adult // the adult layer scaled, adult UVs).
// layer, adult UVs) and keep humanoid / humanoid_leggings.
boolean babyTextures = baby && !s.typeKey().equals("armor_stand"); boolean babyTextures = baby && !s.typeKey().equals("armor_stand");
String folder = babyTextures ? "humanoid_baby" : "humanoid"; String folder = babyTextures ? "humanoid_baby" : "humanoid";
bakeArmorPiece(eq.head(), set + "head", folder, pose, out); bakeArmorPiece(eq.head(), set + "head", folder, pose, out);
@@ -972,7 +910,6 @@ public final class CemBaker implements EntityBaker<EntityState> {
bakeArmorPiece(eq.feet(), set + "feet", folder, pose, out); bakeArmorPiece(eq.feet(), set + "feet", folder, pose, out);
} }
/** Bake one armor slot: its vanilla per-slot model, textured for the material. */
private void bakeArmorPiece( private void bakeArmorPiece(
EntityState.EquipPiece piece, String modelName, String layerFolder, Pose pose, Out out) { EntityState.EquipPiece piece, String modelName, String layerFolder, Pose pose, Out out) {
if (piece == null) return; if (piece == null) return;
@@ -983,7 +920,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (tex != null) add(out, pose, model, tex); 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) { private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset(); String asset = piece.asset();
int[][] base = texture("entity/equipment/" + layerFolder + "/" + asset); int[][] base = texture("entity/equipment/" + layerFolder + "/" + asset);
@@ -1000,7 +937,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out; 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) { private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = texture("trims/entity/" + layerFolder + "/" + pattern); int[][] mask = texture("trims/entity/" + layerFolder + "/" + pattern);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return; if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
@@ -1034,8 +971,9 @@ public final class CemBaker implements EntityBaker<EntityState> {
int[][] tex = texture("entity/equipment/wings/elytra"); int[][] tex = texture("entity/equipment/wings/elytra");
if (tex == null) return; if (tex == null) return;
if (piece.glint()) { if (piece.glint()) {
tex = derived.get(DerivedTextureCache.key("elytra_glint", tex), () -> { int[][] src = tex;
int[][] glinted = TextureOps.deepCopy(texture("entity/equipment/wings/elytra")); tex = derived.get(DerivedTextureCache.key("elytra_glint", src), () -> {
int[][] glinted = TextureOps.deepCopy(src);
applyGlint(glinted); applyGlint(glinted);
return glinted; return glinted;
}); });
@@ -1043,25 +981,16 @@ public final class CemBaker implements EntityBaker<EntityState> {
add(out, pose, model, tex); 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) { 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 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}; int[][] t = tex != null ? tex : new int[][] {{0xFF8C8C8C}};
double[] to = {w / 2, h, w / 2}; ModelCube box = new ModelCube(new double[] {w, h, w}, new double[] {0, 0}, false, null);
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);
Face[] faces = BoxUv.build(box, t, Math.max(64, (int) (2 * (w + w))), Math.max(64, (int) (2 * (w + h)))); 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()) Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
.mul(Affine.scale(1.0 / 16.0)); .mul(Affine.scale(1.0 / 16.0));
List<EntityCube> cubes = new ArrayList<>(); return RenderedEntity.of(List.of(
cubes.add(new EntityCube(from, to, faces, place)); new EntityCube(new double[] {-w / 2, 0, -w / 2}, new double[] {w / 2, h, w / 2}, faces, place)));
return RenderedEntity.of(cubes);
}
private static int[][] flat(int argb) {
int[][] t = new int[1][1];
t[0][0] = argb;
return t;
} }
} }
@@ -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.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv; import eu.mhsl.minecraft.pixelpics.render.entity.BoxUv;
import eu.mhsl.minecraft.pixelpics.render.entity.EntityCube;
import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube; import eu.mhsl.minecraft.pixelpics.render.entity.ModelCube;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -10,10 +11,8 @@ import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
* Shared, stateless CEM geometry baking used by {@link CemBaker} (mobs), the block-entity baker and * Turns a {@link CemModelLoader.CemModel} into {@link Baked} boxes (local box + model→pre-space transform) for
* item geometry. It turns a {@link CemModelLoader.CemModel} into a list of {@link Baked} boxes (local cube + * mobs, block entities and items; placing them in the world is up to the caller.
* its model→pre-space transform), faithfully reproducing the OptiFine/Blockbench CEM transform. The
* placement into the world (yaw, ground-snap, block cell) is the caller's responsibility.
*/ */
final class CemGeometry { 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). // 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}); 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<String> hidden, List<EntityCube> 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) { record Baked(double[] from, double[] to, Face[] faces, Affine world) {
double minWorldY() { double minWorldY() {
double m = Double.MAX_VALUE; 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<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) { static List<Baked> bakeModel(CemModelLoader.CemModel model, int[][] tex, Affine pre, Set<String> hidden) {
return bake(model, tex, pre, hidden, Map.of(), null); return bake(model, tex, pre, hidden, Map.of(), null);
} }
/** /**
* Posed variant for vanilla ("absolute") models: {@code overrides} replace parts' pivot/rotation/visibility * Vanilla models with pose {@code overrides} by part name (pivot/rotation/visibility/scale; boxes follow an
* by name (pose variants: sitting, holding items, armor-stand arms, chests, …; boxes follow their pivot; * overridden pivot). {@code frames}, when non-null, receives each part's frame {@code parent·T(P)·R·S} (also
* a visibility-only override keeps the part's own pivot); {@code frames}, * {@link #PIVOT} and {@link #DRAWN} entries), used to attach items to hands and heads.
* 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.
*/ */
static List<Baked> bakePosed( static List<Baked> bakePosed(
CemModelLoader.CemModel model, CemModelLoader.CemModel model,
@@ -69,7 +78,7 @@ final class CemGeometry {
Ctx ctx = new Ctx(hidden, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames); Ctx ctx = new Ctx(hidden, model.texW(), model.texH(), tex, new ArrayList<>(), overrides, frames);
for (CemModelLoader.CemPart p : model.parts()) { for (CemModelLoader.CemPart p : model.parts()) {
if (model.absolute()) { if (model.absolute()) {
bakeAbsolute(p, pre, ctx); bakeAbsolute(p, pre, ctx, true);
} else { } else {
double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]}; double[] o = {-p.translate()[0], -p.translate()[1], -p.translate()[2]};
bakePart(p, pre, o, 0, ctx); bakePart(p, pre, o, 0, ctx);
@@ -78,10 +87,8 @@ final class CemGeometry {
return ctx.out; 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:"; 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:"; static final String PIVOT = "pivot:";
private record Ctx( private record Ctx(
@@ -94,18 +101,10 @@ final class CemGeometry {
Map<String, Affine> frames) {} Map<String, Affine> frames) {}
/** /**
* Vanilla-dumped ("absolute") part: pivot and boxes are absolute model px, so the group transform is * Vanilla ("absolute") part: {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} like
* simply {@code parent · T(P) · Rz·Ry·Rx · S · T(-P)} — the same order as vanilla's * {@code ModelPart.translateAndRotate}. Invisible parts hide their subtree, {@code skipDraw} only their own
* {@code ModelPart.translateAndRotate}. Invisible parts (vanilla {@code visible=false}, or hidden by a pose * boxes. With frames requested, a hidden subtree is still walked ({@code draw=false}): vanilla attaches held
* override) skip their whole subtree; {@code skipDraw} parts only skip their own boxes. * 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) {
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, …).
*/ */
private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) { private static void bakeAbsolute(CemModelLoader.CemPart part, Affine parentWorld, Ctx ctx, boolean draw) {
if (ctx.hidden.contains(part.name())) return; 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[] scale = ov != null && ov.scale() != null ? ov.scale() : part.scale();
double[] add = ov == null ? null : ov.rotateAdd(); 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[] o = moved ? ov.pivot() : part.pivot();
double[] rot = moved ? ov.rotate() : part.rotate(); 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]}; 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])); world = world.mul(Affine.translation(-o[0], -o[1], -o[2]));
Affine boxWorld = world; Affine boxWorld = world;
if (moved) { if (moved) {
// Boxes are stored relative to the idle pivot: move them along with the overridden pivot // Boxes follow the overridden pivot; children carry their own absolute pivots.
// (children carry their own absolute pivots, so they use the un-shifted transform).
double[] p0 = part.pivot(); double[] p0 = part.pivot();
boxWorld = world.mul(Affine.translation(o[0] - p0[0], o[1] - p0[1], o[2] - p0[2])); 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[] inf = b.inflate();
double[] from = {org[0] - inf[0], org[1] - inf[1], org[2] - inf[2]}; 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]}; 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()); ModelCube mc = new ModelCube(b.size(), b.uv(), b.mirror(), b.faceUv());
// Vanilla texScale stretches the UV normalisation (texture treated as texSize*scale). // Vanilla texScale: UVs normalised by texSize·scale.
int tw = ctx.texW, th = ctx.texH; int tw = ctx.texW, th = ctx.texH;
if (b.texScale() != null) { if (b.texScale() != null) {
tw = (int) Math.round(tw * b.texScale()[0]); 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 * CEM template part (OptiFine/Blockbench import): {@code parent · T(O) · R · T(-O)} with origin {@code O} =
* origin {@code O} (top-level: {@code -translate}; submodel: {@code translate}, accumulated with the * {@code -translate} at the top level, else {@code translate}, accumulated with the parent origin from the
* parent origin from the 2nd nesting level on). Top-level boxes are absolute; nested boxes are offset * 2nd nesting level on. Top-level boxes are absolute, nested ones offset by their group origin.
* by their group origin. The group transform is {@code parent · T(O) · R · T(-O)}.
*/ */
private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) { private static void bakePart(CemModelLoader.CemPart part, Affine parentWorld, double[] o, int depth, Ctx ctx) {
if (ctx.hidden.contains(part.name())) return; if (ctx.hidden.contains(part.name())) return;
@@ -190,7 +187,6 @@ final class CemGeometry {
} }
for (CemModelLoader.CemPart child : part.children()) { for (CemModelLoader.CemPart child : part.children()) {
double[] t = child.translate(); double[] t = child.translate();
// submodel origin = its translate, accumulated with this group's origin from the 2nd level on.
double[] co = 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]}; 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); bakePart(child, world, co, depth + 1, ctx);
@@ -4,42 +4,38 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger; import java.util.logging.Logger;
/** /**
* Loads OptiFine-CEM ({@code .jem}) entity models from the bundled {@code cem_template_models.json} * Loads CEM ({@code .jem} layout) entity models: the vanilla client dump ({@code tools/VanillaModelDump.java},
* (CEM Template Loader data) into baked-ready {@link CemModel}s. These are the vanilla Java entity * "absolute" models plus per-type renderer info, render types and registry variants) and the CEM template set
* models, already in the correct standing pose — no Bedrock geometry / animation needed. * (legacy entity fallback; part {@code translate} = negated pivot, box {@code coordinates} absolute px).
*
* <p>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.
*/ */
public final class CemModelLoader { public final class CemModelLoader {
/** /** A box in px: min corner, size, per-axis inflation, box-UV offset (texels) and mirror. */
* A box: absolute min corner + size (px), inflate, box-UV offset (texels), horizontal texture mirror,
* and optional per-face UV ({@code faceUv}, indexed by {@link eu.mhsl.minecraft.pixelpics.assets.model.Direction}
* ordinal, each {@code {x, y, w, h}} texels; null = use box-UV).
*/
public record CemBox( public record CemBox(
double[] origin, double[] origin,
double[] size, double[] size,
double[] inflate, // per-axis grow (px) double[] inflate,
double[] uv, double[] uv,
boolean mirror, 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 double[] texScale, // vanilla CubeDefinition texScale (UV normalised by texSize*scale); null = 1
boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn boolean[] hiddenFaces) {} // by Direction ordinal; null = all faces drawn
/** A model part: its (raw) translate, rotation (deg), boxes and nested submodels. The rotation pivot /** A model part: CEM translate (legacy pivot rule, see {@link CemGeometry}), rotation (deg), boxes, children. */
* is {@code -(sum of translates from the root to this part)} — accumulated by the baker. */
public record CemPart( public record CemPart(
String name, String name,
double[] translate, double[] translate,
@@ -52,9 +48,8 @@ public final class CemModelLoader {
boolean skipDraw) {} // own boxes not drawn, children are boolean skipDraw) {} // own boxes not drawn, children are
/** /**
* A whole model: declared texture size and its top-level parts. {@code absolute} models (dumped from * A model: declared texture size, top-level parts; {@code absolute} = vanilla dump (absolute pivots and boxes,
* the vanilla client by {@code tools/VanillaModelDump.java}) carry absolute part pivots + boxes and the name * {@code renderType} = the vanilla render type it is drawn with, null = unknown).
* of the render type vanilla draws them with ({@code renderType}, see {@link #renderType}; null = unknown).
*/ */
public record CemModel( public record CemModel(
int texW, int texH, List<CemPart> parts, boolean absolute, String texture, String renderType) {} int texW, int texH, List<CemPart> parts, boolean absolute, String texture, String renderType) {}
@@ -84,7 +79,7 @@ public final class CemModelLoader {
boolean invisible, boolean invisible,
boolean animated) {} 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( public record EntityInfo(
String texture, String texture,
String babyTexture, String babyTexture,
@@ -173,10 +168,6 @@ public final class CemModelLoader {
return asset; 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<String, String> strMap(JsonObject o, String k) { private static Map<String, String> strMap(JsonObject o, String k) {
Map<String, String> out = new HashMap<>(); Map<String, String> out = new HashMap<>();
if (o.has(k) && o.get(k).isJsonObject()) { if (o.has(k) && o.get(k).isJsonObject()) {
@@ -190,13 +181,12 @@ public final class CemModelLoader {
} }
private final Map<String, CemModel> models = new HashMap<>(); private final Map<String, CemModel> models = new HashMap<>();
// Vanilla dump models live in their own namespace so they never shadow CEM-template names the // Own namespace: the dump and the CEM template set use the same names for different models.
// legacy entity fallback relies on (the two sets use different names for different models).
private final Map<String, CemModel> vanilla = new HashMap<>(); private final Map<String, CemModel> vanilla = new HashMap<>();
private final Map<String, EntityInfo> entities = new HashMap<>(); private final Map<String, EntityInfo> entities = new HashMap<>();
private final Map<String, RenderType> renderTypes = new HashMap<>(); private final Map<String, RenderType> renderTypes = new HashMap<>();
// entity type -> variant key ("tabby", or "<ns>:<key>" outside minecraft) -> assets // entity type -> variant key ("tabby", or "<ns>:<key>" outside minecraft) -> assets
private final Map<String, Map<String, VariantAssets>> variants = new java.util.concurrent.ConcurrentHashMap<>(); private final Map<String, Map<String, VariantAssets>> variants = new ConcurrentHashMap<>();
/** Assets of a data-driven variant of {@code typeKey} (registry {@code <typeKey>_variant}), or null. */ /** Assets of a data-driven variant of {@code typeKey} (registry {@code <typeKey>_variant}), or null. */
public VariantAssets variant(String typeKey, String key) { public VariantAssets variant(String typeKey, String key) {
@@ -206,8 +196,7 @@ public final class CemModelLoader {
/** Registers a (datapack) variant definition — the raw {@code <type>_variant} JSON; replaces the vanilla one. */ /** Registers a (datapack) variant definition — the raw {@code <type>_variant} JSON; replaces the vanilla one. */
public void addVariant(String typeKey, String key, JsonObject definition) { public void addVariant(String typeKey, String key, JsonObject definition) {
variants.computeIfAbsent(typeKey, k -> new java.util.concurrent.ConcurrentHashMap<>()) variants.computeIfAbsent(typeKey, k -> new ConcurrentHashMap<>()).put(key, VariantAssets.parse(definition));
.put(key, VariantAssets.parse(definition));
} }
/** A model from the vanilla client dump, or null. */ /** A model from the vanilla client dump, or null. */
@@ -219,8 +208,7 @@ public final class CemModelLoader {
return vanilla.containsKey(name); return vanilla.containsKey(name);
} }
/** All vanilla dump model names. */ public Set<String> vanillaNames() {
public java.util.Set<String> vanillaNames() {
return vanilla.keySet(); return vanilla.keySet();
} }
@@ -234,89 +222,42 @@ public final class CemModelLoader {
return entities.get(typeKey); return entities.get(typeKey);
} }
/** A model of the CEM template set (legacy fallback), or null. */
public CemModel get(String name) { public CemModel get(String name) {
return models.get(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 * Parses a CEM template set or a vanilla dump (recognised by its {@code entities}); later loads override
* loads override earlier models of the same name. Returns the total number of models loaded. * models of the same name. Returns the total number of models loaded so far.
*/ */
public int load(InputStream in, Logger logger) { public int load(InputStream in, Logger logger) {
JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject(); .getAsJsonObject();
boolean isVanillaDump = root.has("entities"); boolean isVanillaDump = root.has("entities");
if (isVanillaDump) loadDumpInfo(root);
Map<String, CemModel> target = isVanillaDump ? vanilla : models; Map<String, CemModel> target = isVanillaDump ? vanilla : models;
if (isVanillaDump) { for (Map.Entry<String, JsonElement> e : root.getAsJsonObject("models").entrySet()) {
for (Map.Entry<String, JsonElement> 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<String, JsonElement> 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<String, JsonElement> t :
root.getAsJsonObject("variants").entrySet()) {
Map<String, VariantAssets> m = new java.util.concurrent.ConcurrentHashMap<>();
for (Map.Entry<String, JsonElement> 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<String, JsonElement> e : modelsObj.entrySet()) {
try { try {
JsonObject entry = e.getValue().getAsJsonObject(); JsonObject entry = e.getValue().getAsJsonObject();
if (!entry.has("model")) continue; if (!entry.has("model")) continue;
// The vanilla dump nests the jem as an object; the CEM template set (and dumps written before // The dump nests the jem as an object; the CEM template set (and older dumps) embed a JSON string.
// 2026-11) embed it as a JSON string that needs a second parse.
JsonElement m = entry.get("model"); JsonElement m = entry.get("model");
JsonObject model = m.isJsonObject() JsonObject model = m.isJsonObject()
? m.getAsJsonObject() ? m.getAsJsonObject()
: JsonParser.parseString(m.getAsString()).getAsJsonObject(); : JsonParser.parseString(m.getAsString()).getAsJsonObject();
int tw = model.getAsJsonArray("textureSize").get(0).getAsInt(); JsonArray size = model.getAsJsonArray("textureSize");
int th = model.getAsJsonArray("textureSize").get(1).getAsInt();
List<CemPart> parts = new ArrayList<>(); List<CemPart> parts = new ArrayList<>();
for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject())); for (JsonElement pe : model.getAsJsonArray("models")) parts.add(parsePart(pe.getAsJsonObject()));
boolean absolute = target.put(
model.has("absolute") && model.get("absolute").getAsBoolean(); e.getKey(),
String texture = model.has("texture") ? model.get("texture").getAsString() : null; new CemModel(
String renderType = size.get(0).getAsInt(),
model.has("renderType") ? model.get("renderType").getAsString() : null; size.get(1).getAsInt(),
target.put(e.getKey(), new CemModel(tw, th, parts, absolute, texture, renderType)); parts,
bool(model, "absolute"),
str(model, "texture"),
str(model, "renderType")));
} catch (Exception ex) { } catch (Exception ex) {
if (logger != null) logger.warning("Failed to parse CEM model " + e.getKey() + ": " + ex.getMessage()); 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(); return models.size() + vanilla.size();
} }
private void loadDumpInfo(JsonObject root) {
for (Map.Entry<String, JsonElement> 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<String, JsonElement> 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<String, JsonElement> t :
root.getAsJsonObject("variants").entrySet()) {
Map<String, VariantAssets> m = new ConcurrentHashMap<>();
for (Map.Entry<String, JsonElement> v :
t.getValue().getAsJsonObject().entrySet()) {
m.put(v.getKey(), VariantAssets.parse(v.getValue().getAsJsonObject()));
}
variants.put(t.getKey(), m);
}
}
}
private CemPart parsePart(JsonObject p) { private CemPart parsePart(JsonObject p) {
double[] translate = arr3(p, "translate"); double[] translate = arr3(p, "translate");
double[] rotate = arr3(p, "rotate"); 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<CemBox> boxes = new ArrayList<>(); List<CemBox> boxes = new ArrayList<>();
if (p.has("boxes")) { if (p.has("boxes")) {
for (JsonElement be : p.getAsJsonArray("boxes")) { for (JsonElement be : p.getAsJsonArray("boxes")) {
@@ -351,17 +337,13 @@ public final class CemModelLoader {
if (b.has("hiddenFaces")) { if (b.has("hiddenFaces")) {
hiddenFaces = new boolean[6]; hiddenFaces = new boolean[6];
for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) { for (JsonElement hf : b.getAsJsonArray("hiddenFaces")) {
hiddenFaces[ hiddenFaces[Direction.fromName(hf.getAsString()).ordinal()] = true;
eu.mhsl.minecraft.pixelpics.assets.model.Direction.fromName(hf.getAsString())
.ordinal()] = true;
} }
} }
double[] uv = b.has("textureOffset") JsonArray to = b.has("textureOffset") ? b.getAsJsonArray("textureOffset") : null;
? new double[] { double[] uv = to == null
b.getAsJsonArray("textureOffset").get(0).getAsDouble(), ? new double[] {0, 0}
b.getAsJsonArray("textureOffset").get(1).getAsDouble() : new double[] {to.get(0).getAsDouble(), to.get(1).getAsDouble()};
}
: new double[] {0, 0};
boxes.add(new CemBox( boxes.add(new CemBox(
origin, size, inflate, uv, partMirror || mirrorsU(b), parseFaceUv(b), texScale, hiddenFaces)); 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() : ""); : (p.has("id") ? p.get("id").getAsString() : "");
double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null; double[] pivot = p.has("pivot") ? arr3(p, "pivot") : null;
double[] scale = p.has("scale") ? arr3(p, "scale") : null; double[] scale = p.has("scale") ? arr3(p, "scale") : null;
boolean invisible = p.has("invisible") && p.get("invisible").getAsBoolean(); return new CemPart(
boolean skipDraw = p.has("skipDraw") && p.get("skipDraw").getAsBoolean(); name, translate, rotate, boxes, children, pivot, scale, bool(p, "invisible"), bool(p, "skipDraw"));
return new CemPart(name, translate, rotate, boxes, children, pivot, scale, invisible, skipDraw);
} }
// CEM per-face UV keys ordered by Direction ordinal (DOWN, UP, NORTH, SOUTH, WEST, EAST). // 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"}; 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) { private static double[][] parseFaceUv(JsonObject b) {
boolean any = false; if (Arrays.stream(FACE_UV_KEYS).noneMatch(b::has)) return null;
for (String k : FACE_UV_KEYS)
if (b.has(k)) {
any = true;
break;
}
if (!any) return null;
double[][] faces = new double[6][]; double[][] faces = new double[6][];
for (int i = 0; i < FACE_UV_KEYS.length; i++) { for (int i = 0; i < FACE_UV_KEYS.length; i++) {
if (!b.has(FACE_UV_KEYS[i])) continue; if (!b.has(FACE_UV_KEYS[i])) continue;
@@ -483,11 +458,11 @@ public final class CemModelLoader {
o.get("model").getAsString(), o.get("model").getAsString(),
o.get("texture").getAsString(), o.get("texture").getAsString(),
strings(o.getAsJsonObject("textures")), 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("alpha") ? o.get("alpha").getAsDouble() : 1,
o.has("when") ? o.get("when").getAsString() : null, str(o, "when"),
o.has("invisible") && o.get("invisible").getAsBoolean(), bool(o, "invisible"),
o.has("animated") && o.get("animated").getAsBoolean())); bool(o, "animated")));
} }
return out; return out;
} }
@@ -502,14 +477,20 @@ public final class CemModelLoader {
private static Map<String, Boolean> layers(JsonObject o) { private static Map<String, Boolean> layers(JsonObject o) {
Map<String, Boolean> out = new HashMap<>(); Map<String, Boolean> out = new HashMap<>();
if (o.has("layers")) { if (o == null) return out;
for (Map.Entry<String, JsonElement> e : o.getAsJsonObject("layers").entrySet()) { for (Map.Entry<String, JsonElement> e : o.entrySet())
out.put(e.getKey(), e.getValue().getAsBoolean()); out.put(e.getKey(), e.getValue().getAsBoolean());
}
}
return out; 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) { private static double[] doubles(JsonObject o, String key) {
if (!o.has(key) || !o.get(key).isJsonArray()) return null; if (!o.has(key) || !o.get(key).isJsonArray()) return null;
JsonArray a = o.getAsJsonArray(key); JsonArray a = o.getAsJsonArray(key);
@@ -9,6 +9,9 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; 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.logging.Logger;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -17,9 +20,8 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipFile; import java.util.zip.ZipFile;
/** /**
* Registers the data-driven mob variants of the worlds' datapacks ({@code data/<ns>/<type>_variant/<key>.json}, * Registers the mob variants of the worlds' datapacks ({@code data/<ns>/<type>_variant/<key>.json}, folder or
* folder or zip packs) with the model loader, next to the vanilla ones from the dump — so a datapack cat/wolf/ * zip packs) with the model loader, overriding the dumped vanilla ones (textures must be in the resource pack).
* cow/… variant renders with the texture its {@code asset_id} names (the resource pack must ship it).
*/ */
public final class DatapackVariants { public final class DatapackVariants {
@@ -35,7 +37,7 @@ public final class DatapackVariants {
*/ */
public static int load(CemModelLoader models, Iterable<File> worldFolders, Logger log) { public static int load(CemModelLoader models, Iterable<File> worldFolders, Logger log) {
int n = 0; int n = 0;
java.util.Set<File> seen = new java.util.HashSet<>(); Set<File> seen = new HashSet<>();
for (File world : worldFolders) { for (File world : worldFolders) {
File dir = world.getAbsoluteFile(); File dir = world.getAbsoluteFile();
for (int up = 0; dir != null && up < 4 && !new File(dir, "datapacks").isDirectory(); up++) { 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; if (!zip.getName().endsWith(".zip")) return 0;
int n = 0; int n = 0;
try (ZipFile zf = new ZipFile(zip)) { 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; if (ze.isDirectory()) continue;
try (InputStream in = zf.getInputStream(ze)) { try (InputStream in = zf.getInputStream(ze)) {
if (register(models, ze.getName(), in)) n++; if (register(models, ze.getName(), in)) n++;
@@ -40,21 +40,16 @@ import org.bukkit.DyeColor;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* Item (and block) geometry the way vanilla's ItemRenderer draws it, straight from the resource pack: * Item (and block) geometry like vanilla's ItemRenderer, from the resource pack: the 26.x item definition
* the 26.x item definition ({@code items/<id>.json}: model / special / select / condition) picks the * ({@code items/<id>.json}) picks the model for the display context, its parent chain supplies the {@code display}
* model for the display context, the model's parent chain supplies the {@code display} transform and the * transform and {@code layerN} textures. Generated items are extruded sprites (1 px thick), block items their block
* {@code layerN} textures. Geometry: generated items as an extruded sprite (1px thick, like vanilla), * model, special models (chest, heads, banners, shield, trident, …) entity/block-entity models. The caller's
* block items as their block model, block-entity items (chest, heads, banners, …) through the block-entity * {@code toWorld} maps the ItemRenderer origin; this class applies the display transform and {@code T(-0.5)}.
* baker, shield/trident through their entity models. Shared by held items, head items, item displays and
* dropped items.
*
* <p>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.
*/ */
public final class ItemGeometry { public final class ItemGeometry {
// Tinted block faces have no biome context here: plains colours. private static final Info EMPTY = new Info(null, List.of(), List.of(), null, null, null);
private static final BiomeTint DEFAULT_TINT = new BiomeTint(0xFF91BD59, 0xFF77AB2F, 0xFFA9A05B, 0xFF3F76E4); private static final double Z0 = 7.5 / 16, Z1 = 8.5 / 16; // extruded sprite depth
private final AssetReader reader; private final AssetReader reader;
private final BlockModelRegistry registry; private final BlockModelRegistry registry;
private final TextureCache textures; private final TextureCache textures;
@@ -89,7 +84,7 @@ public final class ItemGeometry {
// --- public API -------------------------------------------------------------------------------- // --- 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<EntityCube> out) { public void bake(ItemSpec item, String context, boolean leftHand, Affine toWorld, List<EntityCube> out) {
if (item == null) return; if (item == null) return;
Info info = info(item.id(), context); Info info = info(item.id(), context);
@@ -98,7 +93,7 @@ public final class ItemGeometry {
if (info.special != null) bakeSpecial(item, info, m, out); if (info.special != null) bakeSpecial(item, info, m, out);
if (out.size() == before && !info.layers.isEmpty()) bakeGenerated(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); 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) { if (out.size() == before && item.block() != null && item.blockEntity() != null) {
bakeBlockEntity(item.blockEntity(), m, out); bakeBlockEntity(item.blockEntity(), m, out);
} }
@@ -109,9 +104,9 @@ public final class ItemGeometry {
} }
/** /**
* ItemStackRenderState.getModelBoundingBox: the model's quad extents through the display transform * ItemStackRenderState.getModelBoundingBox: the model's extents through {@code display·T(-0.5)} as
* ({@code display·T(-0.5)}), as {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated * {minX, minY, minZ, maxX, maxY, maxZ}; null when nothing renders. Generated items span their whole sprite
* items span their whole sprite square (the 16×16 front/back quads), not just the opaque texels. * square, not just the opaque texels.
*/ */
public double[] modelBounds(ItemSpec item, String context) { public double[] modelBounds(ItemSpec item, String context) {
if (item == null) return null; if (item == null) return null;
@@ -119,58 +114,47 @@ public final class ItemGeometry {
List<EntityCube> cubes = new ArrayList<>(); List<EntityCube> cubes = new ArrayList<>();
if (info.special == null && !info.layers.isEmpty()) { if (info.special == null && !info.layers.isEmpty()) {
Affine m = displayTransform(info.display, false).mul(Affine.translation(-0.5, -0.5, -0.5)); 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 { } else {
bake(item, context, false, Affine.identity(), cubes); bake(item, context, false, Affine.identity(), cubes);
} }
if (cubes.isEmpty()) return null; if (cubes.isEmpty()) return null;
double[] box = { RenderedEntity r = RenderedEntity.of(cubes);
Double.MAX_VALUE, return new double[] {r.aabbMin[0], r.aabbMin[1], r.aabbMin[2], r.aabbMax[0], r.aabbMax[1], r.aabbMax[2]};
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;
} }
/** 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<EntityCube> out) { public void bakeBlock(BlockData data, Affine toWorld, List<EntityCube> out) {
if (data == null) return; if (data == null) return;
bakeModel(registry.get(data), data, toWorld, out); 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<EntityCube> out) { public void bakeModel(ResolvedModel model, BlockData data, Affine toWorld, List<EntityCube> out) {
if (model == null || !model.hasGeometry) return; if (model == null || !model.hasGeometry) return;
for (Element world : model.elements) { for (Element world : model.elements) {
// Rotated: bake the model-space element turned by the blockstate rotation, so textures stay bound // Blockstate-rotated: the model-space element turned as a whole, so textures stay bound to the model.
// to the model like vanilla (uvlocked faces carry their UV transform); otherwise the element as is. Element e = world;
Element e = world.unrotated != null ? world.unrotated : world; Affine variant = Affine.identity();
Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity(); if (world.unrotated != null) {
e = world.unrotated;
variant = variantRotation(world.xSteps, world.ySteps);
}
Face[] faces = new Face[6]; Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data); 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)))); 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<EntityCube> out) { public void bakeBlockEntity(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
// Placed as in the world: cell (0,0,0) with the renderer's block pose.
if (state == null || blockEntities == null) return; if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); RenderedEntity r = blockEntities.bake(state);
if (r == null) return; if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld))); 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<EntityCube> out) { public void bakeBlockEntityModel(BlockEntityState state, Affine toWorld, List<EntityCube> out) {
if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out); if (state != null && blockEntities != null) blockEntities.bakeModel(state, toWorld, out);
} }
@@ -178,14 +162,14 @@ public final class ItemGeometry {
// --- item definition / model resolution -------------------------------------------------------- // --- item definition / model resolution --------------------------------------------------------
private Info info(String id, String context) { 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(); ZonedDateTime now = SpecialDates.now();
return infos.computeIfAbsent(id + "#" + context + "#" + now.toLocalDate(), k -> { return infos.computeIfAbsent(id + "#" + context + "#" + now.toLocalDate(), k -> {
try { try {
return resolve(id, context, now); return resolve(id, context, now);
} catch (RuntimeException e) { } catch (RuntimeException e) {
// Unexpected item-definition format (newer version?): fall back to the plain sprite. // Unexpected item-definition format (newer version?): the plain sprite.
return new Info(null, List.of(), List.of(), null, null, null); return EMPTY;
} }
}); });
} }
@@ -206,13 +190,7 @@ public final class ItemGeometry {
JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null; JsonObject sm = node.has("model") ? node.getAsJsonObject("model") : null;
special = sm == null ? null : str(sm, "type").replace("minecraft:", ""); special = sm == null ? null : str(sm, "type").replace("minecraft:", "");
specialModel = sm; 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]; specialXf = outer[0];
if (node.has("transformation")) {
Affine own = transformation(node.getAsJsonObject("transformation"));
specialXf = specialXf == null ? own : specialXf.mul(own);
}
} else { } else {
modelRef = str(node, "model"); modelRef = str(node, "model");
} }
@@ -223,7 +201,7 @@ public final class ItemGeometry {
} }
if (modelRef == null || modelRef.isEmpty()) modelRef = "minecraft:item/" + id; 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; double[] display = null;
Map<String, String> tex = new HashMap<>(); Map<String, String> tex = new HashMap<>();
boolean generated = false; 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]} * Walks an item-model node to the model/special for a display context. {@code outer[0]} accumulates the
* accumulates the {@code transformation}s of the nodes passed on the way (null = none). * {@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) { private static JsonObject pick(JsonObject node, String context, ZonedDateTime now, Affine[] outer) {
for (int depth = 0; node != null && depth < 16; depth++) { for (int depth = 0; node != null && depth < 16; depth++) {
String type = str(node, "type").replace("minecraft:", ""); 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")); Affine t = transformation(node.getAsJsonObject("transformation"));
outer[0] = outer[0] == null ? t : outer[0].mul(t); outer[0] = outer[0] == null ? t : outer[0].mul(t);
} }
@@ -316,10 +295,7 @@ public final class ItemGeometry {
return null; return null;
} }
/** /** The {@code minecraft:local_time} select value (SimpleDateFormat) for {@code now}; null when invalid. */
* 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.
*/
private static String localTime(JsonObject node, ZonedDateTime now) { private static String localTime(JsonObject node, ZonedDateTime now) {
try { try {
String locale = str(node, "locale"); String locale = str(node, "locale");
@@ -416,9 +392,6 @@ public final class ItemGeometry {
Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m; Affine xf = info.specialTransform != null ? m.mul(info.specialTransform) : m;
switch (info.special) { switch (info.special) {
case "shield" -> { 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"); CemModelLoader.CemModel model = models.vanilla("layer/shield#main");
int[][] tex = shieldTexture(item.blockEntity()); int[][] tex = shieldTexture(item.blockEntity());
if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
@@ -426,36 +399,25 @@ public final class ItemGeometry {
case "trident" -> { case "trident" -> {
CemModelLoader.CemModel model = models.vanilla("trident"); CemModelLoader.CemModel model = models.vanilla("trident");
if (model == null) model = models.vanilla("layer/trident#main"); if (model == null) model = models.vanilla("layer/trident#main");
int[][] tex = textures.get(ResourceLocation.parse("entity/trident/trident")) int[][] tex = texture("entity/trident/trident");
.orElse(null);
if (model != null && tex != null) bakeEntityModel(model, tex, xf, out); if (model != null && tex != null) bakeEntityModel(model, tex, xf, out);
} }
case "chest" -> { case "chest" -> {
// ChestSpecialRenderer: the chest model of the item's chest_type with the item definition's if (!bakeChest(info.specialModel, xf, out)) bakeBlockEntityModel(item.blockEntity(), xf, out);
// texture (Sheets.CHEST_MAPPER: entity/chest/<texture>; 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);
}
} }
// 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<EntityCube> out) { private static void bakeEntityModel(CemModelLoader.CemModel model, int[][] tex, Affine xf, List<EntityCube> out) {
Affine pre = xf.mul(Affine.scale(1 / 16.0)).mul(CemGeometry.CEM_TO_JAVA); CemGeometry.bakeCubes(model, tex, CemGeometry.modelSpace(xf), Set.of(), out);
for (CemGeometry.Baked b : CemGeometry.bakeModel(model, tex, pre, Set.of())) {
out.add(new EntityCube(b.from(), b.to(), b.faces(), b.world()));
}
} }
/** 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/<texture>}); false when unresolvable.
*/
private boolean bakeChest(JsonObject sm, Affine xf, List<EntityCube> out) { private boolean bakeChest(JsonObject sm, Affine xf, List<EntityCube> out) {
if (sm == null || str(sm, "texture").isEmpty()) return false; if (sm == null || str(sm, "texture").isEmpty()) return false;
String layer = String layer =
@@ -466,27 +428,27 @@ public final class ItemGeometry {
}; };
CemModelLoader.CemModel model = models.vanilla(layer); CemModelLoader.CemModel model = models.vanilla(layer);
ResourceLocation id = ResourceLocation.parse(str(sm, "texture")); ResourceLocation id = ResourceLocation.parse(str(sm, "texture"));
int[][] tex = textures.get(ResourceLocation.parse(id.namespace() + ":entity/chest/" + id.path())) int[][] tex = texture(id.namespace() + ":entity/chest/" + id.path());
.orElse(null);
if (model == null || tex == null) return false; if (model == null || tex == null) return false;
bakeEntityModel(model, tex, xf, out); bakeEntityModel(model, tex, xf, out);
return true; 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) { private int[][] shieldTexture(BlockEntityState patterns) {
boolean patterned = patterns != null boolean patterned = patterns != null
&& (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty()); && (patterns.baseColorArgb() != 0 || !patterns.patterns().isEmpty());
int[][] base = textures.get(ResourceLocation.parse( int[][] base = texture(patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern");
patterned ? "entity/shield/shield_base" : "entity/shield/shield_base_nopattern"))
.orElse(null);
if (!patterned || base == null || blockEntities == null) return base; if (!patterned || base == null || blockEntities == null) return base;
int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0); int baseColor = patterns.baseColorArgb() != 0 ? patterns.baseColorArgb() : ColorUtil.dyeArgb(DyeColor.WHITE, 0);
return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns()); return blockEntities.patterned(base, "entity/shield/", baseColor, patterns.patterns());
} }
private void bakeGenerated(Info info, Affine m, List<EntityCube> out) { private void bakeGenerated(Info info, Affine m, List<EntityCube> 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 int[][] composed = composedSprites
.computeIfAbsent(info, k -> Optional.ofNullable(compose(k))) .computeIfAbsent(info, k -> Optional.ofNullable(compose(k)))
.orElse(null); .orElse(null);
@@ -497,36 +459,30 @@ public final class ItemGeometry {
private int[][] compose(Info info) { private int[][] compose(Info info) {
int[][] composed = null; int[][] composed = null;
for (int i = 0; i < info.layers.size(); i++) { 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; if (t == null) continue;
int tint = i < info.tints.size() ? info.tints.get(i) : 0; int tint = i < info.tints.size() ? info.tints.get(i) : 0;
if (composed == null) { if (composed == null) {
composed = TextureOps.deepCopy(t); composed = tint != 0 ? TextureOps.tinted(t, tint) : TextureOps.deepCopy(t);
if (tint != 0) TextureOps.tint(composed, tint);
} else if (t.length == composed.length && t[0].length == composed[0].length) { } else if (t.length == composed.length && t[0].length == composed[0].length) {
int[][] layer = t; TextureOps.overlay(composed, tint != 0 ? TextureOps.tinted(t, tint) : t);
if (tint != 0) {
layer = TextureOps.deepCopy(t);
TextureOps.tint(layer, tint);
}
TextureOps.overlay(composed, layer);
} }
} }
return composed; return composed;
} }
private int[][] sprite(String id) { private int[][] sprite(String id) {
int[][] t = textures.get(ResourceLocation.parse("item/" + id)).orElse(null); int[][] t = texture("item/" + id);
if (t != null) return t; return t != null ? t : texture("block/" + id);
return textures.get(ResourceLocation.parse("block/" + id)).orElse(null); }
private int[][] texture(String path) {
return textures.get(ResourceLocation.parse(path)).orElse(null);
} }
private record Box(double[] from, double[] to, Face[] faces) {} private record Box(double[] from, double[] to, Face[] faces) {}
/** /** Extruded sprite: one box per horizontal run of opaque texels; edges show the run's end texels. */
* 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.
*/
private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) { private void addExtruded(int[][] tex, Affine m, List<EntityCube> out) {
List<Box> boxes; List<Box> boxes;
synchronized (extrusions) { synchronized (extrusions) {
@@ -538,7 +494,6 @@ public final class ItemGeometry {
private static List<Box> extrude(int[][] tex) { private static List<Box> extrude(int[][] tex) {
int h = tex.length, w = h == 0 ? 0 : tex[0].length; int h = tex.length, w = h == 0 ? 0 : tex[0].length;
List<Box> boxes = new ArrayList<>(); List<Box> boxes = new ArrayList<>();
double z0 = 7.5 / 16, z1 = 8.5 / 16;
for (int py = 0; py < h; py++) { for (int py = 0; py < h; py++) {
for (int px = 0; px < w; ) { for (int px = 0; px < w; ) {
if (!ColorUtil.opaque(tex[py][px])) { 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; 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.WEST.ordinal()] = new Face(tex, uw, vm, uw, vm, 0, -1);
f[Direction.EAST.ordinal()] = new Face(tex, ue, vm, ue, 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; return boxes;
@@ -574,7 +529,6 @@ public final class ItemGeometry {
/** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */ /** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */
private static Affine elementTransform(Element e) { private static Affine elementTransform(Element e) {
if (e.isAxisAligned()) return Affine.identity(); if (e.isAxisAligned()) return Affine.identity();
double[] o = e.rotOrigin;
Affine r = Affine r =
switch (e.rotAxis) { switch (e.rotAxis) {
case 0 -> Affine.rotX(e.rotAngleRad); case 0 -> Affine.rotX(e.rotAngleRad);
@@ -587,21 +541,18 @@ public final class ItemGeometry {
double[] f = e.rescaleFactors(); double[] f = e.rescaleFactors();
r = r.mul(Affine.scale(f[0], f[1], f[2])); 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) { private Face tint(Face f, BlockData data) {
if (f == null || f.tintIndex < 0 || data == null) return f; 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; if (color == -1) return f;
int[][] t; int[][] t;
synchronized (tinted) { synchronized (tinted) {
t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>()).computeIfAbsent(color, c -> { t = tinted.computeIfAbsent(f.texture, k -> new HashMap<>())
int[][] copy = TextureOps.deepCopy(f.texture); .computeIfAbsent(color, c -> TextureOps.tinted(f.texture, c));
TextureOps.tint(copy, c);
return copy;
});
} }
return f.withTexture(t, -1); return f.withTexture(t, -1);
} }
@@ -5,27 +5,26 @@ import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
import java.util.List; import java.util.List;
/** /**
* Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at * Rasterizes text lines into a transparent ARGB grid (top-left origin) at the font's native pixel size: lines
* the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas * centred horizontally, blank lines keep their slot, glyphs of all providers on a common baseline, glowing text
* top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays * with an 8-directional outline.
* 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.
*/ */
public final class SignTextRasterizer { public final class SignTextRasterizer {
private 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<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) { public static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
if (font.isEmpty() || lines.isEmpty()) return null; if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent(); int ascent = font.maxAscent();
int pitch = font.lineHeight(); // ascent + descent int pitch = font.lineHeight();
int margin = glow ? 1 : 0; // room for the outline int margin = glow ? 1 : 0; // room for the outline
int[] widths = new int[lines.size()];
int contentW = 1; 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 contentH = Math.max(1, lines.size() * pitch);
int w = contentW + 2 * margin; int w = contentW + 2 * margin;
@@ -33,25 +32,21 @@ public final class SignTextRasterizer {
int[][] out = new int[h][w]; int[][] out = new int[h][w];
boolean[][] covered = new boolean[h][w]; boolean[][] covered = new boolean[h][w];
// Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`.
boolean any = false; boolean any = false;
for (int li = 0; li < lines.size(); li++) { for (int li = 0; li < lines.size(); li++) {
String line = lines.get(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; int baseline = margin + li * pitch + ascent;
for (int ci = 0; ci < line.length(); ) { for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci); int cp = line.codePointAt(ci);
ci += Character.charCount(cp); ci += Character.charCount(cp);
Glyph g = font.glyph(cp); Glyph g = font.glyph(cp);
if (g != null) { if (g != null) any |= blit(g, penX, baseline, fillArgb, out, covered);
any |= blit(g, penX, baseline, fillArgb, out, covered);
}
penX += font.advance(cp); penX += font.advance(cp);
} }
} }
if (!any) return null; if (!any) return null;
// Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels.
if (glow) { if (glow) {
for (int y = 0; y < h; y++) { for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) { for (int x = 0; x < w; x++) {
@@ -70,7 +65,7 @@ public final class SignTextRasterizer {
return out; 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) { private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) {
double scale = g.height() / (double) g.cellH(); double scale = g.height() / (double) g.cellH();
int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5)); 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; if (ox < 0 || ox >= out[0].length) continue;
int sx = g.srcX() + (int) (dx / scale); int sx = g.srcX() + (int) (dx / scale);
int argb = g.tex()[sy][sx]; int argb = g.tex()[sy][sx];
if (((argb >>> 24) & 0xFF) == 0) continue; if ((argb >>> 24) == 0) continue;
out[oy][ox] = fillArgb; out[oy][ox] = fillArgb;
covered[oy][ox] = true; covered[oy][ox] = true;
any = true; any = true;
@@ -95,8 +90,7 @@ public final class SignTextRasterizer {
return any; return any;
} }
/** Total advance width of a line in font px. */ /** Width of a line in font px (sum of glyph advances). */
/** Width of a line in font pixels (sum of glyph advances). */
public static int lineWidth(String line, BitmapFont font) { public static int lineWidth(String line, BitmapFont font) {
int w = 0; int w = 0;
for (int ci = 0; ci < line.length(); ) { for (int ci = 0; ci < line.length(); ) {
@@ -3,4 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.tint;
/** /**
* The biome-dependent tint colors (RGB) for the colormap-driven channels. * 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);
}
@@ -41,9 +41,9 @@ public final class BiomeTintProvider {
private BiomeTint compute(String key) { private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key); BiomeClimate.Climate climate = BiomeClimate.forKey(key);
int grass = channel(climate.grass(), grassMap, climate, 0xFF91BD59); int grass = channel(climate.grass(), grassMap, climate, BiomeTint.PLAINS.grass());
int foliage = channel(climate.foliage(), foliageMap, climate, 0xFF77AB2F); int foliage = channel(climate.foliage(), foliageMap, climate, BiomeTint.PLAINS.foliage());
int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, 0xFFA9A05B); int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, BiomeTint.PLAINS.dryFoliage());
// Vanilla grass_color_modifier (grass only). // Vanilla grass_color_modifier (grass only).
if ("swamp".equals(climate.grassModifier())) { if ("swamp".equals(climate.grassModifier())) {