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