diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java
index 069f028..d900590 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Direction.java
@@ -12,11 +12,34 @@ public enum Direction {
WEST(-1, 0, 0),
EAST(1, 0, 0);
public final int nx, ny, nz;
+ /** The axis of the normal: 0 = x, 1 = y, 2 = z. */
+ public final int axis;
Direction(int nx, int ny, int nz) {
this.nx = nx;
this.ny = ny;
this.nz = nz;
+ this.axis = nx != 0 ? 0 : ny != 0 ? 1 : 2;
+ }
+
+ /** The face of a box on {@code axis}: the low-coordinate one (WEST/DOWN/NORTH) when {@code negative}. */
+ public static Direction of(int axis, boolean negative) {
+ return switch (axis) {
+ case 0 -> negative ? WEST : EAST;
+ case 1 -> negative ? DOWN : UP;
+ default -> negative ? NORTH : SOUTH;
+ };
+ }
+
+ public Direction opposite() {
+ return switch (this) {
+ case DOWN -> UP;
+ case UP -> DOWN;
+ case NORTH -> SOUTH;
+ case SOUTH -> NORTH;
+ case WEST -> EAST;
+ case EAST -> WEST;
+ };
}
public static Direction fromName(String name) {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java
index 54c0d4e..c148652 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Element.java
@@ -147,6 +147,13 @@ public final class Element {
/** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */
public static double[] rotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
+ double[] out = new double[3];
+ rotate(out, x, y, z, xSteps, ySteps, point);
+ return out;
+ }
+
+ /** {@link #rotate} into {@code out} (allocation-free, for the intersectors). */
+ public static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c;
for (int i = 0; i < xSteps; i++) {
@@ -159,13 +166,22 @@ public final class Element {
dx = nx;
dz = nz;
}
- return new double[] {dx + c, dy + c, dz + c};
+ out[0] = dx + c;
+ out[1] = dy + c;
+ out[2] = dz + c;
}
/** World -> model space: the inverse of {@link #rotate}. */
public static double[] unrotate(double x, double y, double z, int xSteps, int ySteps, boolean point) {
- double[] p = rotate(x, y, z, 0, (4 - ySteps) % 4, point);
- return rotate(p[0], p[1], p[2], (4 - xSteps) % 4, 0, point);
+ double[] out = new double[3];
+ unrotate(out, x, y, z, xSteps, ySteps, point);
+ return out;
+ }
+
+ /** {@link #unrotate} into {@code out}. */
+ public static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
+ rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
+ rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
}
public boolean isAxisAligned() {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/Affine.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/Affine.java
index 5b7ac86..4dd0166 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/Affine.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/Affine.java
@@ -1,13 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
/**
- * A 3x3 linear transform plus translation (affine), used to compose entity bone hierarchies and to
- * map a cube's local space to world space. The {@code apply} parameter {@code t} of a ray is
- * preserved under affine maps, so ray distances stay consistent between world and local space.
+ * A 3x3 linear transform (row-major) plus translation. Ray parameters {@code t} are preserved under affine maps, so
+ * distances stay consistent between world and local space.
*/
public final class Affine {
- // row-major 3x3
public final double[] r;
public final double[] t;
@@ -16,8 +14,13 @@ public final class Affine {
this.t = t;
}
+ /** A linear map (no translation) from its row-major matrix. */
+ private static Affine linear(double... r) {
+ return new Affine(r, new double[] {0, 0, 0});
+ }
+
public static Affine identity() {
- return new Affine(new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}, new double[] {0, 0, 0});
+ return linear(1, 0, 0, 0, 1, 0, 0, 0, 1);
}
public static Affine translation(double x, double y, double z) {
@@ -39,63 +42,62 @@ public final class Affine {
y /= n;
z /= n;
w /= n;
- return new Affine(
- new double[] {
- 1 - 2 * (y * y + z * z),
- 2 * (x * y - z * w),
- 2 * (x * z + y * w),
- 2 * (x * y + z * w),
- 1 - 2 * (x * x + z * z),
- 2 * (y * z - x * w),
- 2 * (x * z - y * w),
- 2 * (y * z + x * w),
- 1 - 2 * (x * x + y * y)
- },
- new double[] {0, 0, 0});
+ return linear(
+ 1 - 2 * (y * y + z * z),
+ 2 * (x * y - z * w),
+ 2 * (x * z + y * w),
+ 2 * (x * y + z * w),
+ 1 - 2 * (x * x + z * z),
+ 2 * (y * z - x * w),
+ 2 * (x * z - y * w),
+ 2 * (y * z + x * w),
+ 1 - 2 * (x * x + y * y));
}
/** Right-handed rotation by {@code a} radians about the unit axis {@code (x, y, z)} (Rodrigues). */
public static Affine rotAxis(double x, double y, double z, double a) {
double c = Math.cos(a), s = Math.sin(a), k = 1 - c;
- return new Affine(
- new double[] {
- c + x * x * k,
- x * y * k - z * s,
- x * z * k + y * s,
- y * x * k + z * s,
- c + y * y * k,
- y * z * k - x * s,
- z * x * k - y * s,
- z * y * k + x * s,
- c + z * z * k
- },
- new double[] {0, 0, 0});
+ return linear(
+ c + x * x * k,
+ x * y * k - z * s,
+ x * z * k + y * s,
+ y * x * k + z * s,
+ c + y * y * k,
+ y * z * k - x * s,
+ z * x * k - y * s,
+ z * y * k + x * s,
+ c + z * z * k);
}
public static Affine scale(double s) {
- return new Affine(new double[] {s, 0, 0, 0, s, 0, 0, 0, s}, new double[] {0, 0, 0});
+ return linear(s, 0, 0, 0, s, 0, 0, 0, s);
}
public static Affine scale(double sx, double sy, double sz) {
- return new Affine(new double[] {sx, 0, 0, 0, sy, 0, 0, 0, sz}, new double[] {0, 0, 0});
+ return linear(sx, 0, 0, 0, sy, 0, 0, 0, sz);
}
public static Affine rotX(double a) {
double c = Math.cos(a), s = Math.sin(a);
- return new Affine(new double[] {1, 0, 0, 0, c, -s, 0, s, c}, new double[] {0, 0, 0});
+ return linear(1, 0, 0, 0, c, -s, 0, s, c);
}
public static Affine rotY(double a) {
double c = Math.cos(a), s = Math.sin(a);
- return new Affine(new double[] {c, 0, s, 0, 1, 0, -s, 0, c}, new double[] {0, 0, 0});
+ return linear(c, 0, s, 0, 1, 0, -s, 0, c);
}
public static Affine rotZ(double a) {
double c = Math.cos(a), s = Math.sin(a);
- return new Affine(new double[] {c, -s, 0, s, c, 0, 0, 0, 1}, new double[] {0, 0, 0});
+ return linear(c, -s, 0, s, c, 0, 0, 0, 1);
}
- /** this ∘ o (apply o first, then this). */
+ /** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
+ public static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
+ return translation(cx, cy, cz).mul(rot).mul(translation(-cx, -cy, -cz));
+ }
+
+ /** this ∘ o (apply o first, then this). */
public Affine mul(Affine o) {
double[] a = this.r, b = o.r;
double[] nr = new double[9];
@@ -121,17 +123,16 @@ public final class Affine {
};
}
- /** Linear part only (for directions). */
- public double[] applyLinear(double x, double y, double z) {
- return new double[] {
- r[0] * x + r[1] * y + r[2] * z, r[3] * x + r[4] * y + r[5] * z, r[6] * x + r[7] * y + r[8] * z
- };
+ public double determinant() {
+ return r[0] * (r[4] * r[8] - r[5] * r[7])
+ - r[1] * (r[3] * r[8] - r[5] * r[6])
+ + r[2] * (r[3] * r[7] - r[4] * r[6]);
}
- /** General affine inverse (3x3 inverse + translation). */
+ /** Affine inverse; a singular matrix yields a zero linear part. */
public Affine inverse() {
double a = r[0], b = r[1], c = r[2], d = r[3], e = r[4], f = r[5], g = r[6], h = r[7], i = r[8];
- double det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
+ double det = determinant();
double inv = Math.abs(det) < 1e-12 ? 0 : 1.0 / det;
double[] ir = new double[] {
(e * i - f * h) * inv, (c * h - b * i) * inv, (b * f - c * e) * inv,
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java
index 041e6d6..59dd8f3 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityModels.java
@@ -6,16 +6,10 @@ import java.util.List;
import java.util.Set;
/**
- * Maps a {@link BlockEntityState} to the models its vanilla BlockEntityRenderer draws (from the client
- * dump, see {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose below is
- * transcribed from the decompiled 26.3 client renderers (ChestRenderer, SkullBlockRenderer,
- * BannerRenderer, ShulkerBoxRenderer, DecoratedPotRenderer, BellRenderer, ConduitRenderer,
- * LecternRenderer, EnchantTableRenderer, CopperGolemStatueBlockRenderer, Standing/HangingSignRenderer) — no
- * calibration offsets. Mirrors the role
- * {@link EntityModels} plays for mobs.
- *
- *
Beds and sign boards are plain block models since 26.x (no renderer model): the block-model
- * pipeline draws them; only the sign TEXT is added here.
+ * Maps a {@link BlockEntityState} to the dumped models its vanilla BlockEntityRenderer draws (see
+ * {@code tools/VanillaModelDump.java}) and to the renderer's PoseStack. Every pose is transcribed from the decompiled
+ * 26.3 client renderers, without calibration offsets. Beds and sign boards are block models since 26.x; only the sign
+ * text is added here.
*/
public final class BlockEntityModels {
@@ -38,7 +32,11 @@ public final class BlockEntityModels {
*/
public record Part(String model, List textures, Set hidden, Paint paint) {
static Part of(String model, String texture) {
- return new Part(model, List.of(texture), Set.of(), Paint.PLAIN);
+ return of(model, List.of(texture));
+ }
+
+ static Part of(String model, List textures) {
+ return new Part(model, textures, Set.of(), Paint.PLAIN);
}
}
@@ -63,8 +61,8 @@ public final class BlockEntityModels {
case SINGLE -> "layer/chest#main";
};
yield new VanillaModel(
- rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
- List.of(new Part(layer, chestTextures(s), Set.of(), Paint.PLAIN)));
+ Affine.rotationAround(Affine.rotY(Math.toRadians(-deg)), 0.5, 0, 0.5),
+ List.of(Part.of(layer, chestTextures(s))));
}
// SkullBlockRenderer ground: T(0.5,0,0.5)·rotY(-segment·22.5)·S(-1,-1,1); wall: T(0.5 - step·0.25,
// 0.25, …)·rotY(-opposite.toYRot())·S(-1,-1,1).
@@ -105,17 +103,12 @@ public final class BlockEntityModels {
.mul(directionRotation(s.direction()))
.mul(Affine.scale(1, -1, -1))
.mul(Affine.translation(0, -1, 0)),
- List.of(new Part(
- "layer/shulker_box#main",
- s.colorName() == null
- ? List.of("entity/shulker/shulker")
- : List.of("entity/shulker/shulker_" + s.colorName(), "entity/shulker/shulker"),
- Set.of(),
- Paint.PLAIN)));
+ List.of(Part.of(
+ "layer/shulker_box#main", variantThenBase("entity/shulker/shulker", s.colorName()))));
// DecoratedPotRenderer: rotateAround(Y, 180 - facing.toYRot(), .5,.5,.5); neck/top/bottom from the
// base sheet, each side its sherd pattern (or the blank side).
case DECORATED_POT -> new VanillaModel(
- rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
+ Affine.rotationAround(Affine.rotY(Math.toRadians(180 - deg)), 0.5, 0.5, 0.5), potParts(s));
// BellRenderer: the model as is (pivot 8,12,8 in block px); the frame is the block model.
case BELL -> new VanillaModel(
Affine.identity(), List.of(Part.of("layer/bell#main", "entity/bell/bell_body")));
@@ -151,15 +144,9 @@ public final class BlockEntityModels {
// (CopperGolemStatueModel.setupAnim: root.y 0, root.zRot π), texture per weathering stage.
case COPPER_GOLEM_STATUE -> new VanillaModel(
Affine.translation(0.5, 0, 0.5).mul(Affine.rotY(Math.toRadians(-(deg + 180)))),
- List.of(new Part(
+ List.of(Part.of(
"posed/copper_golem_statue_" + (s.pose() == null ? "standing" : s.pose()) + "#main",
- s.colorName() == null
- ? List.of("entity/copper_golem/copper_golem")
- : List.of(
- "entity/copper_golem/copper_golem_" + s.colorName(),
- "entity/copper_golem/copper_golem"),
- Set.of(),
- Paint.PLAIN)));
+ variantThenBase("entity/copper_golem/copper_golem", s.colorName()))));
// Signs: text only (BlockEntityBaker). Items, spawner mobs, beams and portals are not entity models
// (BlockEntityBaker.addContents).
case SIGN,
@@ -176,10 +163,9 @@ public final class BlockEntityModels {
}
/**
- * The sign renderer's text transformation (Standing/HangingSignRenderer.textTransformation) WITHOUT
- * its final {@code scale(s, -s, s)}: returns the frame whose origin is the text anchor (centre of the
- * four lines), x right / y up / +z towards the reader, and the font-pixel size {@code s} in blocks via
- * {@code out[0]}. Back text is the front transform turned by 180°.
+ * Standing/HangingSignRenderer.textTransformation WITHOUT its final {@code scale(s, -s, s)}: the frame whose
+ * origin is the text anchor (centre of the four lines), x right / y up / +z towards the reader; the font-pixel
+ * size {@code s} in blocks goes to {@code pxSize[0]}. Back text is the front transform turned by 180°.
*/
public static Affine signTextFrame(BlockEntityState s, boolean front, double[] pxSize) {
double deg = s.facingDeg();
@@ -243,15 +229,14 @@ public final class BlockEntityModels {
* trapped chests between 24 and 26 December ({@link SpecialDates#isExtendedChristmas}), else normal/trapped.
*/
private static List chestTextures(BlockEntityState s) {
+ if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
boolean xmas = SpecialDates.isExtendedChristmas();
String base =
switch (s.kind()) {
case TRAPPED_CHEST -> xmas ? "christmas" : "trapped";
- case ENDER_CHEST -> "ender";
case COPPER_CHEST -> s.colorName() == null ? "copper" : "copper_" + s.colorName();
default -> xmas ? "christmas" : "normal";
};
- if (s.kind() == BlockEntityState.Kind.ENDER_CHEST) return List.of("entity/chest/ender");
return switch (chestKind(s)) {
case LEFT -> List.of("entity/chest/" + base + "_left", "entity/chest/" + base);
case RIGHT -> List.of("entity/chest/" + base + "_right", "entity/chest/" + base);
@@ -259,9 +244,9 @@ public final class BlockEntityModels {
};
}
- /** JOML {@code rotationAround}: {@code T(c) · rot · T(-c)}. */
- private static Affine rotationAround(Affine rot, double cx, double cy, double cz) {
- return Affine.translation(cx, cy, cz).mul(rot).mul(Affine.translation(-cx, -cy, -cz));
+ /** {@code _} first, then the base texture; just the base without a variant. */
+ private static List variantThenBase(String base, String variant) {
+ return variant == null ? List.of(base) : List.of(base + "_" + variant, base);
}
/** Minecraft {@code Direction.getRotation()} (JOML rotationXYZ = Rx·Ry·Rz); null/unknown = UP. */
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java
index 660e3dc..df4eaa5 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BlockEntityState.java
@@ -3,14 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
- * Immutable snapshot of one block-entity captured on the main thread, sufficient to bake and place it
- * off-thread. Block-entities drawn by a vanilla BlockEntityRenderer (chests, banners, heads, shulker
- * boxes, …) have no block-model geometry; their shape is the renderer's entity model (dumped from the
- * client, see {@link BlockEntityModels}), rendered through the same baking/intersection path as mobs.
- * Bukkit-free so the baker needs no world access.
- *
- * {@code facingDeg} is the vanilla yaw of the block's facing/rotation (0 = south, 90 = west; a
- * 16-step rotation is {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
+ * Bukkit-free snapshot of one block-entity, captured on the main thread and baked off-thread through the entity path
+ * (see {@link BlockEntityModels}). {@code facingDeg} is the vanilla yaw of the facing/rotation (0 = south, 90 = west;
+ * 16-step rotations are {@code segment · 22.5}). Type-specific fields are null/0/false/empty when unused.
*/
public record BlockEntityState(
Kind kind,
@@ -35,7 +30,7 @@ public record BlockEntityState(
String pose // copper golem statue pose ("standing", "running", "sitting", "star"); null otherwise
) {
- /** The pre-contents layout (no items/entity/beam/pose): chests, signs, banners, heads, … */
+ /** Without contents (items, display entity, beam, pose); used by the test harnesses. */
public BlockEntityState(
Kind kind,
int bx,
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BoxUv.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BoxUv.java
index b1386d0..750949d 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BoxUv.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/BoxUv.java
@@ -4,18 +4,16 @@ import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
/**
- * Unwraps a {@link ModelCube} into six {@link Face}s using the standard Minecraft/Bedrock box-UV
- * layout (faces packed in the canonical cross around the {@code uv} offset). Mirror swaps the
- * left/right faces and flips each face horizontally.
+ * Unwraps a {@link ModelCube} into six {@link Face}s with the Java box-UV layout (the cross around the {@code uv}
+ * offset). Mirror swaps the left/right faces and flips each face horizontally.
*/
public final class BoxUv {
private BoxUv() {}
/**
- * Returns Faces indexed by {@link Direction#ordinal()}. UVs are normalized by the model's DECLARED
- * texel size (so a higher-res pack texture — e.g. a 128x128 sheet for a model authored at 64x64 —
- * still maps proportionally, same layout). Falls back to the actual texture size if undeclared.
+ * Faces by {@link Direction#ordinal()}. UVs are normalised by the model's DECLARED texture size, so a
+ * higher-resolution pack texture maps proportionally; the actual size is used when undeclared.
*/
public static Face[] build(ModelCube cube, int[][] texture, int declaredW, int declaredH) {
int texW = texture.length > 0 ? texture[0].length : 64;
@@ -23,7 +21,6 @@ public final class BoxUv {
int nW = declaredW > 0 ? declaredW : texW;
int nH = declaredH > 0 ? declaredH : texH;
- // Modern per-face UV: each face carries its own {u, v, w, h} rect directly.
if (cube.faceUv != null) {
Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) {
@@ -35,9 +32,8 @@ public final class BoxUv {
double dx = cube.size[0], dy = cube.size[1], dz = cube.size[2];
double u = cube.uv[0], v = cube.uv[1];
- // rect = {x, y, w, h} in texels, SIGNED — a negative width/height flips that axis. These match
- // the OptiFine/Blockbench box-UV layout EXACTLY (up/down are flipped), paired with the (s,t) the
- // EntityIntersector feeds in, so every face's texture orientation matches vanilla Java.
+ // {x, y, w, h} texels, SIGNED (negative = flipped axis): the Blockbench/OptiFine box-UV layout (up/down
+ // flipped). Must stay in sync with the face (s, t) orientation of ElementIntersector.faceS/faceT.
double[] east = {u, v + dz, dz, dy};
double[] west = {u + dz + dx, v + dz, dz, dy};
double[] north = {u + dz, v + dz, dx, dy};
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java
index 42f4c80..62e4554 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationBaker.java
@@ -10,9 +10,8 @@ import java.util.ArrayList;
import java.util.List;
/**
- * Bakes wall decorations into world-space {@link EntityCube}s: paintings as flat textured quads built from the
- * captured world bounding box, item frames as their vanilla frame block model plus the framed item (item
- * model geometry) or map. Added to the {@link EntityScene} like any other entity.
+ * Bakes wall decorations: paintings as a thin box over the captured world bounding box, item frames as their vanilla
+ * frame block model plus the framed item or map.
*/
public final class DecorationBaker implements EntityBaker {
@@ -44,9 +43,9 @@ public final class DecorationBaker implements EntityBaker {
thicken(from, to, s.facing());
Face[] faces = new Face[6];
- Direction front = direction(s.facing());
+ Direction front = Direction.valueOf(s.facing().name());
faces[front.ordinal()] = frontFace(art, s.facing());
- faces[opposite(front).ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
+ faces[front.opposite().ordinal()] = new Face(back, 0, 0, 1, 1, 0, -1);
EntityCube cube = new EntityCube(from, to, faces, Affine.identity());
return RenderedEntity.of(List.of(cube));
}
@@ -81,7 +80,7 @@ public final class DecorationBaker implements EntityBaker {
List cubes = new ArrayList<>();
boolean map = s.mapPixels() != null;
- if (!s.invisible() && registry != null && items != null) {
+ if (!s.invisible()) {
String model = "block/" + (s.glow() ? "glow_item_frame" : "item_frame") + (map ? "_map" : "");
items.bakeModel(
registry.model(ResourceLocation.parse(model)),
@@ -95,13 +94,13 @@ public final class DecorationBaker implements EntityBaker {
.mul(Affine.rotZ(Math.PI))
.mul(Affine.scale(1 / 128.0))
.mul(Affine.translation(-64, -64, -1));
- // Quad (0,0)..(128,128) at z −0.01 facing local −Z (away from the wall); u along +x, v along +y.
+ // Quad (0,0)..(128,128) facing local −Z (away from the wall); u along +x, v along +y.
Face[] faces = new Face[6];
faces[Direction.NORTH.ordinal()] = new Face(s.mapPixels(), 1, 1, 0, 0, 0, -1);
faces[Direction.SOUTH.ordinal()] = new Face(s.mapPixels(), 0, 1, 1, 0, 0, -1);
cubes.add(new EntityCube(new double[] {0, 0, -0.01}, new double[] {128, 128, -0.01}, faces, m));
addMapMarkers(s, m, cubes);
- } else if (s.item() != null && items != null) {
+ } else if (s.item() != null) {
Affine m =
content.mul(Affine.rotZ(Math.toRadians(s.rotation() * 45))).mul(Affine.scale(0.5));
items.bake(s.item(), "fixed", false, m, cubes);
@@ -115,22 +114,18 @@ public final class DecorationBaker implements EntityBaker {
* (-1,1)..(1,-1) at z = n·-0.001 (n-th drawn decoration), {@code map/decorations/} with v0 at +y.
*/
private void addMapMarkers(DecorationState s, Affine map, List cubes) {
- int count = 0;
- for (DecorationState.MapMarker mk : s.mapMarkers()) {
+ List markers = s.mapMarkers();
+ for (int n = 0; n < markers.size(); n++) {
+ DecorationState.MapMarker mk = markers.get(n);
int[][] sprite = textures.get(ResourceLocation.parse("map/decorations/" + mk.asset()))
.orElse(null);
- if (sprite != null) {
- Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
- .mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
- .mul(Affine.scale(4, 4, 3))
- .mul(Affine.translation(-0.125, 0.125, 0));
- double z = count * -0.001;
- Face[] faces = new Face[6];
- faces[Direction.SOUTH.ordinal()] = new Face(sprite, 0, 0, 1, 1, 0, -1);
- faces[Direction.NORTH.ordinal()] = new Face(sprite, 1, 0, 0, 1, 0, -1);
- cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, faces, m));
- }
- count++;
+ if (sprite == null) continue;
+ Affine m = map.mul(Affine.translation(mk.x() / 2.0 + 64, mk.y() / 2.0 + 64, -0.02))
+ .mul(Affine.rotZ(Math.toRadians(mk.rot() * 360 / 16.0)))
+ .mul(Affine.scale(4, 4, 3))
+ .mul(Affine.translation(-0.125, 0.125, 0));
+ double z = n * -0.001;
+ cubes.add(new EntityCube(new double[] {-1, -1, z}, new double[] {1, 1, z}, ObjectBaker.card(sprite), m));
}
}
@@ -145,38 +140,16 @@ public final class DecorationBaker implements EntityBaker {
}
/** Front face with the per-facing horizontal flip so the texture reads upright and unmirrored. */
- private Face frontFace(int[][] tex, DecorationState.Facing facing) {
+ private static Face frontFace(int[][] tex, DecorationState.Facing facing) {
boolean flipH = facing == DecorationState.Facing.SOUTH || facing == DecorationState.Facing.WEST;
double u1 = flipH ? 1 : 0, u2 = flipH ? 0 : 1;
return new Face(tex, u1, 0, u2, 1, 0, -1);
}
- private void thicken(double[] from, double[] to, DecorationState.Facing facing) {
+ private static void thicken(double[] from, double[] to, DecorationState.Facing facing) {
int a = facing.axis();
if (to[a] - from[a] >= MIN_THICKNESS) return;
if (facing.sign() > 0) from[a] = to[a] - MIN_THICKNESS;
else to[a] = from[a] + MIN_THICKNESS;
}
-
- private static Direction direction(DecorationState.Facing f) {
- return switch (f) {
- case NORTH -> Direction.NORTH;
- case SOUTH -> Direction.SOUTH;
- case EAST -> Direction.EAST;
- case WEST -> Direction.WEST;
- case UP -> Direction.UP;
- case DOWN -> Direction.DOWN;
- };
- }
-
- private static Direction opposite(Direction d) {
- return switch (d) {
- case NORTH -> Direction.SOUTH;
- case SOUTH -> Direction.NORTH;
- case EAST -> Direction.WEST;
- case WEST -> Direction.EAST;
- case UP -> Direction.DOWN;
- case DOWN -> Direction.UP;
- };
- }
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java
index ca14a1a..e7d2429 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DecorationState.java
@@ -3,11 +3,8 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
/**
- * Immutable snapshot of a flat wall decoration (painting or item frame). These are Bukkit
- * {@code Hanging} entities, not block-entities, but render as flat textured quads rather than CEM
- * models, so they have their own state/baker. The world-space bounding box is captured directly (it
- * already encodes vanilla's awkward multi-block painting offset), avoiding placement math. Item frames use
- * its centre (the vanilla entity position).
+ * Snapshot of a wall decoration (painting or item frame). The world bounding box is captured directly: it already
+ * encodes vanilla's multi-block painting offset, and its centre is the item frame's entity position.
*/
public record DecorationState(
Kind kind,
@@ -27,7 +24,7 @@ public record DecorationState(
List mapMarkers // framed map's decorations shown on item frames (banners, frames, …); may be empty
) {
- /** The layout without map markers (paintings, frames without a map or with an undecorated one). */
+ /** Without map markers; used by the test harnesses. */
public DecorationState(
Kind kind,
double minX,
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DerivedTextureCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DerivedTextureCache.java
index 33a0bdb..71b0588 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DerivedTextureCache.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/DerivedTextureCache.java
@@ -7,16 +7,11 @@ import java.util.Map;
import java.util.function.Supplier;
/**
- * Bounded, thread-safe LRU cache of DERIVED textures — tinted / composited copies the bakers build per entity
- * (sheep wool colours, villager type + profession + level overlays, dyed leather armor, collars, banner
- * flags, …) — so identical mobs share one texture instead of re-tinting it on every bake.
+ * Bounded, thread-safe LRU cache of derived (tinted/composited) textures, so identical mobs share one texture.
*
- * Keys are built with {@link #key}: a value list of the inputs that determine the result. Source pixel
- * grids may be part of the key; arrays compare by identity, which is exact because no grid is mutated once
- * it is built (texture-cache grids live for the plugin's lifetime). Cached grids are shared and must not be
- * mutated by callers.
- * The computation runs outside the lock; concurrent misses on the same key may compute twice, the first
- * stored result wins.
+ *
Keys ({@link #key}) list the inputs that determine the result; source grids compare by identity, which is exact
+ * because no grid is mutated once built. Cached grids are shared: callers must not mutate them. The computation runs
+ * outside the lock; concurrent misses may compute twice, the first stored result wins.
*/
public final class DerivedTextureCache {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityBaker.java
index dd641e5..8560893 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityBaker.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityBaker.java
@@ -1,10 +1,6 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
-/**
- * Bakes a captured state {@code S} (mob, block-entity or decoration) into a {@link RenderedEntity},
- * or returns {@code null} when it has no renderable geometry. Lets {@link EntityScene} treat all three
- * render paths uniformly.
- */
+/** Bakes a captured state into a {@link RenderedEntity}, or null when it has no geometry. */
public interface EntityBaker {
RenderedEntity bake(S state);
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java
index 09c2de4..3916995 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java
@@ -3,17 +3,16 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
/**
- * A baked entity cube in world space: a local box (model pixels) plus the affine transform mapping it
- * into the world, its six faces, the precomputed inverse transform and a world-space AABB for
- * broad-phase culling.
+ * A baked entity cube: a local box, the affine map into the world, its six faces (by {@code Direction.ordinal()},
+ * null = not drawn), the inverse map and the world-space AABB.
*/
public final class EntityCube {
- public final double[] from; // local min (px, inflated)
+ public final double[] from; // local min (inflated)
public final double[] to; // local max
- public final Face[] faces; // by Direction.ordinal()
+ public final Face[] faces;
public final Affine toWorld;
- public final Affine toLocal; // inverse
- public final boolean additive; // emissive, added to what lies behind (energy swirl) instead of blended
+ public final Affine toLocal;
+ public final boolean additive; // added to what lies behind (energy swirl) instead of blended
public final boolean unshaded; // no directional face shading (vanilla NO_CARDINAL_LIGHTING: eyes, breeze wind)
// Block-model element with from > to on some axis (spawner/vault inner faces): its faces point INTO the box
// and are back-face culled like vanilla quads (see EntityIntersector); mirrored = toWorld flips handedness.
@@ -38,19 +37,16 @@ public final class EntityCube {
this.toWorld = toWorld;
this.toLocal = toWorld.inverse();
this.inverted = from[0] > to[0] || from[1] > to[1] || from[2] > to[2];
- double[] r = toWorld.r;
- double det = r[0] * (r[4] * r[8] - r[5] * r[7])
- - r[1] * (r[3] * r[8] - r[5] * r[6])
- + r[2] * (r[3] * r[7] - r[4] * r[6]);
- this.mirrored = det < 0;
+ this.mirrored = toWorld.determinant() < 0;
+ double[] r = toWorld.r, t = toWorld.t;
for (int i = 0; i < 8; i++) {
double x = (i & 1) == 0 ? from[0] : to[0];
double y = (i & 2) == 0 ? from[1] : to[1];
double z = (i & 4) == 0 ? from[2] : to[2];
- double[] w = toWorld.apply(x, y, z);
for (int a = 0; a < 3; a++) {
- if (w[a] < aabbMin[a]) aabbMin[a] = w[a];
- if (w[a] > aabbMax[a]) aabbMax[a] = w[a];
+ double w = r[a * 3] * x + r[a * 3 + 1] * y + r[a * 3 + 2] * z + t[a];
+ if (w < aabbMin[a]) aabbMin[a] = w;
+ if (w > aabbMax[a]) aabbMax[a] = w;
}
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java
index f05dfc8..4e7e28c 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java
@@ -8,11 +8,10 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector;
/**
- * Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped
- * into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels
- * are treated as holes (alpha cutout); partially transparent ones keep their alpha for the tracer to blend.
- * The {@code t} is a world-space distance. The test writes into a caller-owned {@link Hit} and allocates
- * nothing; {@link #toFaceHit} builds the result object only for the hit the caller finally keeps.
+ * Intersects a world-space ray with a single {@link EntityCube}: the ray is mapped into the cube's local frame and
+ * slab-tested (t stays the world-space one). Cut-out texels are holes; partially transparent ones keep their alpha
+ * for the tracer to blend. The test writes into a caller-owned {@link Hit} and allocates nothing; {@link #toFaceHit}
+ * builds the result only for the hit the caller keeps.
*/
public final class EntityIntersector {
@@ -25,16 +24,13 @@ public final class EntityIntersector {
public static final class Hit {
public double t;
public int color;
- int axis;
- boolean neg;
+ Direction dir;
final double[] o = new double[3], d = new double[3];
}
/**
- * Nearest drawn face of the cube with {@code t > minT} (entry face first, else the far face); {@code minT}
- * lets the tracer continue behind a translucent hit. Cubes whose entry lies beyond {@code maxT} are
- * rejected early (no texture lookup); a hit at exactly {@code maxT} is still reported. On a hit the face
- * is left in {@code out}.
+ * Nearest drawn face with {@code t > minT} (entry face first, else the far face). Cubes entered beyond
+ * {@code maxT} are rejected before any texture lookup; a hit at exactly {@code maxT} still counts.
*/
public static boolean intersect(
EntityCube cube,
@@ -55,9 +51,10 @@ public final class EntityIntersector {
d[0] = r[0] * dx + r[1] * dy + r[2] * dz;
d[1] = r[3] * dx + r[4] * dy + r[5] * dz;
d[2] = r[6] * dx + r[7] * dy + r[8] * dz;
- double[] from = cube.from, to = cube.to;
- if (cube.inverted) return intersectInverted(cube, o, d, Math.max(EPS, minT), maxT, out);
+ double lo = Math.max(EPS, minT);
+ if (cube.inverted) return intersectInverted(cube, o, d, lo, maxT, out);
+ double[] from = cube.from, to = cube.to;
double tmin = Double.NEGATIVE_INFINITY, tmax = Double.POSITIVE_INFINITY;
int inAxis = -1, outAxis = -1;
boolean inNeg = false, outNeg = false;
@@ -67,9 +64,7 @@ public final class EntityIntersector {
continue;
}
double inv = 1.0 / d[a];
- // Moving towards +a the ray enters through the min ("negative") face and leaves through the
- // max face. Decided by the direction, not by comparing t1/t2: for a zero-thickness box
- // (wing membranes, fins) both are equal and the comparison can't tell the sides apart.
+ // Entry side from the direction, not from t1 > t2: both are equal for zero-thickness boxes (wings, fins).
boolean forward = inv > 0;
double tNear = ((forward ? from[a] : to[a]) - o[a]) * inv;
double tFar = ((forward ? to[a] : from[a]) - o[a]) * inv;
@@ -85,105 +80,65 @@ public final class EntityIntersector {
}
if (tmin > tmax + EPS) return false;
}
- double lo = Math.max(EPS, minT);
if (inAxis < 0 || tmax <= lo || tmin > maxT) return false;
- // Vanilla draws entity models without back-face culling: a ray looking through a transparent
- // texel of the entry face still sees the inside of the far face (and both sides of a plane).
- if (tmin > lo && sample(cube, o, d, tmin, inAxis, inNeg, out)) return true;
- return outAxis >= 0 && sample(cube, o, d, tmax, outAxis, outNeg, out);
+ // Vanilla draws entity models without back-face culling: through a transparent texel of the entry face the
+ // ray sees the inside of the far face (and both sides of a plane).
+ if (tmin > lo && sample(cube, o, d, tmin, Direction.of(inAxis, inNeg), out)) return true;
+ return outAxis >= 0 && sample(cube, o, d, tmax, Direction.of(outAxis, outNeg), out);
}
- private static boolean sample(EntityCube cube, double[] o, double[] d, double t, int axis, boolean neg, Hit out) {
- Direction dir = direction(axis, neg);
+ private static boolean sample(EntityCube cube, double[] o, double[] d, double t, Direction dir, Hit out) {
Face face = cube.faces[dir.ordinal()];
if (face == null) return false;
- double px = o[0] + d[0] * t, py = o[1] + d[1] * t, pz = o[2] + d[2] * t;
- double fx = frac(px, cube.from[0], cube.to[0]);
- double fy = frac(py, cube.from[1], cube.to[1]);
- double fz = frac(pz, cube.from[2], cube.to[2]);
- // Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv).
+ double fx = ElementIntersector.frac(o[0] + d[0] * t, cube.from[0], cube.to[0]);
+ double fy = ElementIntersector.frac(o[1] + d[1] * t, cube.from[1], cube.to[1]);
+ double fz = ElementIntersector.frac(o[2] + d[2] * t, cube.from[2], cube.to[2]);
int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
if (!ColorUtil.opaque(color)) return false;
out.t = t;
out.color = color;
- out.axis = axis;
- out.neg = neg;
+ out.dir = dir;
return true;
}
/**
- * A block-model element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces) baked as
- * an item or display: like {@code ElementIntersector.invertedBox}, vanilla puts the WEST/DOWN/NORTH face at
- * {@code from} and EAST/UP/SOUTH at {@code to} of its axis, and each inverted OTHER axis mirrors the quad and
- * flips its winding — the faces point into the box and are back-face culled (seen through the cage: the far
- * wall's inside). Nearest visible opaque face in {@code (lo, maxT]} wins; UVs follow the mirrored corners.
+ * A block-model element with {@code from > to} on some axis (spawner/vault inner faces) baked as an item or
+ * display: back-face-culled inward quads like {@link ElementIntersector} (a mirroring {@code toWorld} flips their
+ * winding too). The nearest opaque face in {@code (lo, maxT]} wins.
*/
private static boolean intersectInverted(EntityCube cube, double[] o, double[] d, double lo, double maxT, Hit out) {
double[] from = cube.from, to = cube.to;
double best = Double.POSITIVE_INFINITY;
for (Direction dir : DIRECTIONS) {
- int a = dir.nx != 0 ? 0 : dir.ny != 0 ? 1 : 2;
- if (Math.abs(d[a]) < EPS) continue;
- boolean neg = dir.nx + dir.ny + dir.nz < 0;
- int b = (a + 1) % 3, c = (a + 2) % 3;
- int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
- double sign = (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (cube.mirrored ? -1 : 1);
- if (sign * d[a] >= 0) continue; // back face
- double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
+ if (Math.abs(d[dir.axis]) < EPS) continue;
+ double t = ElementIntersector.invertedFaceT(dir, from, to, o, d, cube.mirrored);
if (!(t > lo) || t > maxT || t >= best) continue;
- double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
- if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) continue;
- if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) continue;
- Face face = cube.faces[dir.ordinal()];
- if (face == null) continue;
- double fx = signedFrac(o[0] + d[0] * t, from[0], to[0]);
- double fy = signedFrac(o[1] + d[1] * t, from[1], to[1]);
- double fz = signedFrac(o[2] + d[2] * t, from[2], to[2]);
- int color = face.sample(ElementIntersector.faceS(dir, fx, fy, fz), ElementIntersector.faceT(dir, fy, fz));
- if (!ColorUtil.opaque(color)) continue;
- best = t;
- out.t = t;
- out.color = color;
- out.axis = a;
- out.neg = neg;
+ if (sample(cube, o, d, t, dir, out)) best = t;
}
return best < Double.POSITIVE_INFINITY;
}
- /** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
- private static double signedFrac(double v, double from, double to) {
- double span = to - from;
- if (Math.abs(span) < 1e-9) return 0;
- double f = (v - from) / span;
- return f < 0 ? 0 : Math.min(f, 1);
- }
-
/** The result object for a hit of {@code cube} recorded by {@link #intersect} (world point + normal). */
public static FaceHit toFaceHit(
EntityCube cube, Hit h, double ox, double oy, double oz, double dx, double dy, double dz) {
double t = h.t;
- Direction dir = direction(h.axis, h.neg);
- Vector world = new Vector(ox + dx * t, oy + dy * t, oz + dz * t);
- double[] n = cube.toWorld.applyLinear(dir.nx, dir.ny, dir.nz);
- Vector normal = new Vector(n[0], n[1], n[2]).normalize();
+ Direction dir = h.dir;
+ double[] r = cube.toWorld.r;
+ Vector normal = new Vector(
+ r[0] * dir.nx + r[1] * dir.ny + r[2] * dir.nz,
+ r[3] * dir.nx + r[4] * dir.ny + r[5] * dir.nz,
+ r[6] * dir.nx + r[7] * dir.ny + r[8] * dir.nz)
+ .normalize();
// A far/back face is seen from inside: shade it as facing the viewer.
if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1);
- return new FaceHit(t, world, normal, h.color, -1, cube.additive, cube.unshaded);
- }
-
- private static Direction direction(int axis, boolean neg) {
- return switch (axis) {
- case 0 -> neg ? Direction.WEST : Direction.EAST;
- case 1 -> neg ? Direction.DOWN : Direction.UP;
- default -> neg ? Direction.NORTH : Direction.SOUTH;
- };
- }
-
- private static double frac(double v, double lo, double hi) {
- double span = hi - lo;
- if (span < 1e-6) return 0;
- double f = (v - lo) / span;
- return f < 0 ? 0 : Math.min(f, 1);
+ return new FaceHit(
+ t,
+ new Vector(ox + dx * t, oy + dy * t, oz + dz * t),
+ normal,
+ h.color,
+ -1,
+ cube.additive,
+ cube.unshaded);
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityModels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityModels.java
index 661d2bd..493c295 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityModels.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityModels.java
@@ -6,16 +6,14 @@ import java.util.List;
import java.util.Map;
/**
- * Maps an entity type to its CEM ({@code .jem}) Java model name and candidate texture paths. Most types
- * use the type key directly for both; small override maps handle the exceptions. The models are vanilla
- * Java models (already posed); variant-specific textures (cow/sheep colour, etc.) are handled here.
+ * Entity type -> CEM template model name (legacy fallback) and candidate texture paths. Most types use the type key
+ * directly; small override maps handle the exceptions.
*/
public final class EntityModels {
private EntityModels() {}
- // Type key -> CEM (.jem) model name. Default is the type key itself; these are the exceptions
- // (mob reuses another mob's model, or the CEM set only ships a version-suffixed/renamed name).
+ // Exceptions to "model name = type key": reused models and renamed/version-suffixed CEM names.
private static final Map CEM_OVERRIDE = Map.ofEntries(
Map.entry("husk", "zombie"),
Map.entry("giant", "zombie"),
@@ -46,7 +44,7 @@ public final class EntityModels {
/** The CEM model name for an entity type (boats/rafts share the boat hull). */
public static String cemModel(String typeKey) {
- if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return "boat";
+ if (isBoat(typeKey)) return "boat";
return CEM_OVERRIDE.getOrDefault(typeKey, typeKey);
}
@@ -54,7 +52,7 @@ public final class EntityModels {
// or not the right default: variant-only mobs whose renderer default is a variant/"missingno", the dump's
// piglin_brute (= piglin), players/mannequins, and non-mob entities whose renderer texture isn't captured.
// Everything else comes from the dump (see CemBaker#resolveTexture).
- private static final Map TEX_OVERRIDE = Map.ofEntries(
+ private static final Map TEX_OVERRIDE = Map.ofEntries(
Map.entry("cow", "entity/cow/cow_temperate"),
Map.entry("player", "entity/player/wide/steve"),
Map.entry("ender_dragon", "entity/enderdragon/dragon"),
@@ -80,7 +78,7 @@ public final class EntityModels {
/** Ordered texture-path candidates; the baker uses the first that loads. */
public static List textureCandidates(String typeKey, String variant) {
List list = new ArrayList<>();
- if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) {
+ if (isBoat(typeKey)) {
String wood = typeKey.substring(0, typeKey.length() - 5);
boolean chest = wood.endsWith("_chest");
if (chest) wood = wood.substring(0, wood.length() - "_chest".length());
@@ -100,17 +98,15 @@ public final class EntityModels {
/** How many leading {@link #textureCandidates} entries are type/variant-specific (not generic guesses). */
public static int specificCandidateCount(String typeKey, String variant) {
- if (typeKey.endsWith("_boat") || typeKey.endsWith("_raft")) return 1;
+ if (isBoat(typeKey)) return 1;
return (variant != null ? variantPaths(typeKey, variant).size() : 0)
+ (TEX_OVERRIDE.containsKey(typeKey) ? 1 : 0);
}
/**
- * Variant-specific texture paths of the ENUM variants vanilla hardcodes in its renderers (modern pack
- * naming is "entity//_", with a handful of mismatches the small maps below
- * normalise). Data-driven variants (cat, wolf, frog, cow, pig, chicken, zombie nautilus) are resolved from
- * their {@code asset_id} in the vanilla dump instead. Returned paths are tried before the generic
- * fallbacks, so an unknown variant still degrades to the base texture.
+ * Texture paths of the ENUM variants vanilla hardcodes in its renderers ("entity//_",
+ * mismatches normalised by the maps below). Registry variants (cat, wolf, frog, cow, …) come from their
+ * {@code asset_id} in the dump instead. Tried before the generic fallbacks.
*/
private static List variantPaths(String typeKey, String v) {
return switch (typeKey) {
@@ -135,6 +131,10 @@ public final class EntityModels {
};
}
+ private static boolean isBoat(String typeKey) {
+ return typeKey.endsWith("_boat") || typeKey.endsWith("_raft");
+ }
+
/** Vanilla tropical-fish patterns in id order: 0-5 use body shape A (small), 6-11 shape B (large). */
private static final List TROPICAL_PATTERNS = List.of(
"kob",
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java
index 91a5f61..3ed890f 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityScene.java
@@ -4,16 +4,15 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
+import java.util.logging.Logger;
/**
- * All baked entities (mobs + block-entities + decorations + objects) for one render. Provides the nearest hit
- * along a ray. Every entity cube goes into one bounding volume hierarchy over the cubes' world AABBs (binned
- * SAH, flat arrays), traversed front to back with the current best hit as the far bound, so a ray only tests
- * the cubes near it instead of every entity — farms with hundreds of mobs/items cost about as much as a few.
- * Near-ties (coplanar faces of different cubes) are decided by replaying the former linear scan over the few
- * candidates, so images stay pixel-identical. Immutable after construction; the per-ray scratch is per thread
- * → safe for the parallel tracer.
+ * All baked entities (mobs, block-entities, decorations, objects) of one render, as one bounding volume hierarchy
+ * over the cubes' world AABBs (binned SAH, flat arrays), traversed front to back with the best hit as far bound.
+ * Near-ties (coplanar faces of different cubes) are decided in bake order (see {@link #nearestHit}). Immutable after
+ * construction; the per-ray scratch is per thread.
*/
public final class EntityScene {
@@ -23,8 +22,6 @@ public final class EntityScene {
private static final int MAX_SAH_DEPTH = 40;
private static volatile boolean warned;
- private final List entities;
-
// Cubes in BVH leaf order, their bake-order rank (tie-break) and world AABBs (minX..maxZ, 6 per cube).
private final EntityCube[] cubes;
private final int[] cubeRank;
@@ -52,11 +49,11 @@ public final class EntityScene {
EntityIntersector.Hit candidate(int k) {
if (k == cand.length) {
int len = Math.max(8, k * 2);
- EntityIntersector.Hit[] c = java.util.Arrays.copyOf(cand, len);
+ EntityIntersector.Hit[] c = Arrays.copyOf(cand, len);
for (int i = k; i < len; i++) c[i] = new EntityIntersector.Hit();
cand = c;
- candCube = java.util.Arrays.copyOf(candCube, len);
- order = java.util.Arrays.copyOf(order, len);
+ candCube = Arrays.copyOf(candCube, len);
+ order = Arrays.copyOf(order, len);
}
return cand[k];
}
@@ -73,17 +70,13 @@ public final class EntityScene {
DecorationBaker decoBaker,
List objects,
ObjectBaker objectBaker) {
- this.entities = new ArrayList<>(states.size() + blockEntities.size() + decorations.size() + objects.size());
- addAll(states, baker);
- addAll(blockEntities, beBaker);
- addAll(decorations, decoBaker);
- addAll(objects, objectBaker);
-
- int n = 0;
- for (RenderedEntity e : entities) n += e.cubes.size();
- EntityCube[] all = new EntityCube[n];
- int k = 0;
- for (RenderedEntity e : entities) for (EntityCube c : e.cubes) all[k++] = c;
+ List baked = new ArrayList<>();
+ addAll(states, baker, baked);
+ addAll(blockEntities, beBaker, baked);
+ addAll(decorations, decoBaker, baked);
+ addAll(objects, objectBaker, baked);
+ EntityCube[] all = baked.toArray(new EntityCube[0]);
+ int n = all.length;
cubes = new EntityCube[n];
cubeRank = new int[n];
@@ -91,28 +84,27 @@ public final class EntityScene {
maxDepth = build(all);
}
- /** Bakes each state and keeps the ones with geometry. A null baker (path disabled) is skipped. */
- private void addAll(List states, EntityBaker baker) {
+ /** Bakes each state into {@code out} (in bake order = tie-break rank). A null baker (path disabled) is skipped. */
+ private static void addAll(List states, EntityBaker baker, List out) {
if (baker == null) return;
for (S s : states) {
RenderedEntity e;
try {
e = baker.bake(s);
} catch (RuntimeException ex) {
- // One unexpected entity/asset must never fail the whole photo: skip just this one.
+ // One bad entity/asset must never fail the whole photo.
if (!warned) {
warned = true;
- java.util.logging.Logger.getLogger("PixelPics")
- .warning("PixelPics: skipped an entity that failed to bake: " + ex);
+ Logger.getLogger("PixelPics").warning("PixelPics: skipped an entity that failed to bake: " + ex);
}
continue;
}
- if (e != null && !e.cubes.isEmpty()) entities.add(e);
+ if (e != null) out.addAll(e.cubes);
}
}
public boolean isEmpty() {
- return entities.isEmpty();
+ return cubes.length == 0;
}
/** Nearest entity hit with {@code minT < t < maxT}, or null. */
@@ -164,34 +156,30 @@ public final class EntityScene {
int l = node + 1, r = nodeRight[node];
double tl = entry(nb, l, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
double trr = entry(nb, r, ox, oy, oz, ix, iy, iz, px, py, pz, t0, bound);
- boolean hl = tl == tl, hr = trr == trr;
- if (hl && hr) {
- // Near child on top of the stack: visited first, so the far one is usually culled.
- if (tl <= trr) {
- stack[sp] = r;
- stackT[sp++] = trr;
- stack[sp] = l;
- stackT[sp++] = tl;
- } else {
- stack[sp] = l;
- stackT[sp++] = tl;
- stack[sp] = r;
- stackT[sp++] = trr;
- }
- } else if (hl) {
- stack[sp] = l;
- stackT[sp++] = tl;
- } else if (hr) {
- stack[sp] = r;
- stackT[sp++] = trr;
+ // Near child on top of the stack: visited first, so the far one is usually culled. NaN = missed.
+ int near = l, far = r;
+ double tn = tl, tf = trr;
+ if (!(tl <= trr)) {
+ near = r;
+ far = l;
+ tn = trr;
+ tf = tl;
+ }
+ if (tf == tf) {
+ stack[sp] = far;
+ stackT[sp++] = tf;
+ }
+ if (tn == tn) {
+ stack[sp] = near;
+ stackT[sp++] = tn;
}
}
}
if (n == 0) return null;
- // Pass 2: replay the former linear scan over the near candidates in bake order — a cube counts only if
- // its AABB passes with the best t so far as far bound and it is strictly nearer. Coplanar faces of
- // different cubes (equal t up to rounding) thus resolve exactly as before: pixel-identical images.
+ // Pass 2, the tie-break: the near candidates in bake order; a cube counts only if its AABB passes with the
+ // best t so far as far bound and it is strictly nearer. Coplanar faces of different cubes (equal t up to
+ // rounding) depend on this order, so it must not change.
int[] candCube = tr.candCube;
int[] order = tr.order;
int m = 0;
@@ -221,8 +209,7 @@ public final class EntityScene {
/**
* Slab test of box {@code i} of {@code boxes} (6 doubles per box) against the ray within {@code [t0, tMax]};
- * returns the entry t, or NaN on a miss. Same comparisons as the former per-entity/per-cube AABB check
- * (a box touching the interval at {@code tMax} still counts).
+ * returns the entry t, or NaN on a miss (a box touching the interval at {@code tMax} still counts).
*/
private static double entry(
double[] boxes,
@@ -352,7 +339,7 @@ public final class EntityScene {
}
if (mid < 0) {
// Leaf: cubes in bake order inside the leaf (ascending original index).
- java.util.Arrays.sort(perm, lo, hi);
+ Arrays.sort(perm, lo, hi);
nodeStart[node] = filled;
nodeCount[node] = count;
for (int i = lo; i < hi; i++) {
@@ -410,7 +397,7 @@ public final class EntityScene {
for (int b = 0; b < SAH_BINS - 1; b++) {
grow(acc, binBox, b);
cnt += binCount[b];
- leftArea[b] = area(acc);
+ leftArea[b] = area(acc, 0);
leftCount[b] = cnt;
}
acc[0] = acc[1] = acc[2] = Double.MAX_VALUE;
@@ -423,7 +410,7 @@ public final class EntityScene {
cnt += binCount[b];
int left = leftCount[b - 1];
if (left == 0 || cnt == 0) continue;
- double cost = leftArea[b - 1] * left + area(acc) * cnt;
+ double cost = leftArea[b - 1] * left + area(acc, 0) * cnt;
if (cost < bestCost) {
bestCost = cost;
bestSplit = b;
@@ -459,7 +446,7 @@ public final class EntityScene {
private void sortByCentroid(int lo, int hi, int axis) {
Integer[] tmp = new Integer[hi - lo];
for (int i = lo; i < hi; i++) tmp[i - lo] = perm[i];
- java.util.Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
+ Arrays.sort(tmp, (a, b) -> Double.compare(centroid[a * 3 + axis], centroid[b * 3 + axis]));
for (int i = lo; i < hi; i++) perm[i] = tmp[i - lo];
}
@@ -470,10 +457,6 @@ public final class EntityScene {
}
}
- private static double area(double[] box) {
- return area(box, 0);
- }
-
private static double area(double[] box, int o) {
double x = box[o + 3] - box[o], y = box[o + 4] - box[o + 1], z = box[o + 5] - box[o + 2];
if (!(x >= 0) || !(y >= 0) || !(z >= 0)) return 0;
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java
index 9397d5e..6e7f082 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
-/**
- * Immutable snapshot of one entity captured on the main thread, sufficient to bake and pose it
- * off-thread. Angles are in degrees (Minecraft convention).
- */
+import java.util.Map;
+
+/** Snapshot of one entity captured on the main thread, enough to bake it off-thread. Angles in degrees. */
public record EntityState(
String typeKey, // e.g. "cow", "zombie", "player"
double x,
@@ -16,8 +15,7 @@ public record EntityState(
boolean player,
String skinUrl,
boolean slim,
- String variant, // variant key (e.g. "ashen", "warm", "tabby"; ":" outside minecraft); for villagers
- // the biome type, or null
+ String variant, // variant key ("ashen", "tabby"; ":" outside minecraft; villagers: biome type)
int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none
double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default
String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null
@@ -26,11 +24,10 @@ public record EntityState(
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest (pose flag "chest")
boolean arms, // armor stand shows its arms (pose flag "arms")
- String bodyEquip, // BODY-slot equipment asset (horse armor material, llama carpet, harness, wolf armor,
- // nautilus armor) or "trader_llama"; null = none
+ String bodyEquip, // BODY-slot equipment asset (horse armor, llama carpet, harness, …) or "trader_llama"
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible, // entity is invisible -> render only its equipment (like vanilla), not the body
- float pitch, // entity pitch (deg) — orients projectiles (arrows, tridents, skulls)
+ float pitch, // orients projectiles and pitched bodies
int bodyDye, // ARGB dye of dyeable BODY equipment (leather horse armor, wolf armor); 0 = none
int tint2, // second tint (tropical-fish pattern colour); 0 = none
boolean passenger, // riding something (vanilla sitting pose)
@@ -40,10 +37,9 @@ public record EntityState(
int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed)
boolean powered, // energy-swirl layer: charged creeper, wither below half health
ItemSpec mouthItem, // item carried in the mouth (fox/dolphin main hand); null = none
- String pose, // state pose flags, sorted and "+"-joined ("sitting", "lying+head_up", "sleeping", "in_water",
- // …; the dump's STATE_POSES names, unknown ones are ignored per type); null = none (idle)
- java.util.Map partPoses, // continuous part poses by vanilla render-state field (armor stand
- // "headPose"/"bodyPose"/"leftArmPose"/…: x/y/z degrees); null = defaults
+ String pose, // "+"-joined state pose/render flags (the dump's STATE_POSES names, e.g. "lying+head_up"); null =
+ // idle
+ Map partPoses, // armor-stand part rotations by render-state field ("headPose", …), x/y/z deg
String shoulderLeft, // parrot variant on a player's left shoulder (e.g. "red"); null = none
String shoulderRight // parrot variant on the right shoulder; null = none
) {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ItemSpec.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ItemSpec.java
index 3e91e88..0527ca6 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ItemSpec.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ItemSpec.java
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import org.bukkit.block.data.BlockData;
/**
- * An item to render (held, worn on the head, in an item display, dropped): its item key, the block data
- * when the item places a block (block-model geometry), the block-entity state when that block is
- * block-entity rendered (chests, heads, banners, …), and whether it is enchanted. Captured on the main
- * thread; immutable afterwards.
+ * An item to render (held, worn, displayed, dropped): item key, the block it places (null if none), that block's
+ * block-entity state when it is block-entity rendered, and whether it is enchanted.
*/
public record ItemSpec(String id, BlockData block, BlockEntityState blockEntity, boolean glint) {}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java
index 23e93bc..a692821 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ModelCube.java
@@ -1,16 +1,13 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
-/**
- * A single box of an entity model, in Bedrock model-pixel coordinates (16 px = 1 block).
- * {@code origin} is the minimum corner, {@code uv} is the box-UV texture offset.
- */
+/** One box of an entity model in model pixels: min corner, size, inflation and box-UV offset (or per-face UVs). */
public final class ModelCube {
- public final double[] origin; // 3, min corner (px)
- public final double[] size; // 3 (px)
- public final double inflate; // px, expands the box on all sides (overlay layers)
- public final double[] uv; // 2, box-UV offset (texels)
+ 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 boolean mirror;
- /** Optional modern per-face UV, indexed by {@code Direction.ordinal()}: {u, v, w, h} texels (h/w may be negative for flips). Null = use box-UV. */
+ // 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) {
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
index e7c3b81..8e4a6a1 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectBaker.java
@@ -7,18 +7,17 @@ import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.SignTextRasterizer;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
/**
- * Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart
- * contents) into world-space {@link EntityCube}s: block content reuses the resolved BLOCK MODEL elements
- * (any block, any version — straight from the resource pack), items use their block model or a flat
- * sprite, text is rasterized with the pack's bitmap font (as for signs).
+ * Bakes {@link ObjectState}s (display entities, dropped items, falling blocks, primed TNT, minecart contents,
+ * leashes): blocks and items through {@link ItemGeometry}, text rasterized with the pack's bitmap font.
*/
public final class ObjectBaker implements EntityBaker {
- // Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
- private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
+ // Vanilla LeashRenderer colours, alternating per segment.
+ private static final Face[] LEASH_LIGHT = solidFaces(0xFF7F6650), LEASH_DARK = solidFaces(0xFF59473A);
private static final int LEASH_SEGMENTS = 24;
private static final double LEASH_HALF_WIDTH = 0.025;
@@ -35,8 +34,7 @@ public final class ObjectBaker implements EntityBaker {
List cubes = new ArrayList<>();
switch (s.kind()) {
case BLOCK -> {
- // The block model plus what its block-entity renderer adds (chest/banner/… bodies, the lectern's
- // book, the bell, the enchanting-table book): block-entity blocks draw no block-model geometry.
+ // Block model plus the block-entity renderer's parts (chest bodies, lectern book, bell, …).
items.bakeBlock(s.block(), s.toWorld(), cubes);
items.bakeBlockEntity(s.blockEntity(), s.toWorld(), cubes);
}
@@ -70,15 +68,14 @@ public final class ObjectBaker implements EntityBaker {
for (int i = 1; i <= LEASH_SEGMENTS; i++) {
double t = (double) i / LEASH_SEGMENTS;
double[] cur = {p[0] + dx * t, p[1] + dy * (t * t + t) * 0.5, p[2] + dz * t};
- int color = (i % 2 == 0) ? LEASH_LIGHT : LEASH_DARK;
- EntityCube seg = segment(prev, cur, color);
+ EntityCube seg = segment(prev, cur, i % 2 == 0 ? LEASH_LIGHT : LEASH_DARK);
if (seg != null) cubes.add(seg);
prev = cur;
}
}
/** A thin square rope box from a to b. */
- private static EntityCube segment(double[] a, double[] b, int argb) {
+ private static EntityCube segment(double[] a, double[] b, Face[] faces) {
double[] d = {b[0] - a[0], b[1] - a[1], b[2] - a[2]};
double len = Math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-6) return null;
@@ -89,11 +86,14 @@ public final class ObjectBaker implements EntityBaker {
double[] v = cross(d, u);
Affine frame = new Affine(
new double[] {u[0], v[0], d[0], u[1], v[1], d[1], u[2], v[2], d[2]}, new double[] {a[0], a[1], a[2]});
- int[][] solid = {{argb}};
- Face[] f = new Face[6];
- for (int i = 0; i < 6; i++) f[i] = new Face(solid, 0, 0, 1, 1, 0, -1);
double w = LEASH_HALF_WIDTH;
- return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, f, frame);
+ return new EntityCube(new double[] {-w, -w, 0}, new double[] {w, w, len + w}, faces, frame);
+ }
+
+ private static Face[] solidFaces(int argb) {
+ Face[] f = new Face[6];
+ Arrays.fill(f, new Face(new int[][] {{argb}}, 0, 0, 1, 1, 0, -1));
+ return f;
}
private static double[] cross(double[] a, double[] b) {
@@ -110,21 +110,23 @@ public final class ObjectBaker implements EntityBaker {
private void addText(ObjectState s, List cubes) {
if (font == null || font.isEmpty()) return;
List lines = wrap(s.lines(), Math.max(1, s.lineWidth()));
- boolean blank = lines.stream().allMatch(String::isBlank);
- int[][] text = blank ? null : SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
- int w = text == null ? 0 : text[0].length, h = text == null ? 0 : text.length;
- if (text != null && s.shadow()) text = withShadow(text, s.textArgb());
-
- // Text block: bottom edge on the origin, horizontally centred, front facing +Z.
- double x0 = -w / 2.0, x1 = x0 + (text == null ? 0 : text[0].length);
- double y1 = h, y0 = text == null ? 0 : h - text.length;
- if (text != null) {
- cubes.add(
- new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld()));
+ int w = 0, h = 0;
+ if (!lines.stream().allMatch(String::isBlank)) {
+ int[][] text = SignTextRasterizer.rasterize(lines, font, s.textArgb(), 0, false);
+ w = text[0].length;
+ h = text.length;
+ if (s.shadow()) text = withShadow(text, s.textArgb());
+ // Bottom edge on the origin (the shadow hangs below), horizontally centred, front facing +Z.
+ double x0 = -w / 2.0;
+ cubes.add(new EntityCube(
+ new double[] {x0, h - text.length, 0.05},
+ new double[] {x0 + text[0].length, h, 0.05},
+ card(text),
+ s.toWorld()));
}
int bg = s.backgroundArgb();
// Translucent backgrounds (vanilla default: 25% black) keep their alpha; the tracer blends them.
- if (ColorUtil.opaque(bg) && (w > 0 || !s.lines().isEmpty())) {
+ if (ColorUtil.opaque(bg) && !s.lines().isEmpty()) {
int[][] solid = {{bg}};
cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
@@ -135,7 +137,7 @@ public final class ObjectBaker implements EntityBaker {
* Faces of a planar (z-thickness 0) card: front (+Z) and back show the SAME texel at each point (the
* back is seen mirrored, as in vanilla), so a ray through a transparent texel misses both sides.
*/
- private static Face[] card(int[][] tex) {
+ static Face[] card(int[][] tex) {
Face[] f = new Face[6];
f[Direction.SOUTH.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, -1);
f[Direction.NORTH.ordinal()] = new Face(tex, 1, 0, 0, 1, 0, -1);
@@ -171,8 +173,10 @@ public final class ObjectBaker implements EntityBaker {
int h = text.length, w = text[0].length;
int[][] out = new int[h + 1][w + 1];
int shadow = ColorUtil.shade(argb, 0.25);
- for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y + 1][x + 1] = shadow;
- for (int y = 0; y < h; y++) for (int x = 0; x < w; x++) if ((text[y][x] >>> 24) > 0) out[y][x] = text[y][x];
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y + 1][x + 1] = shadow;
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++) if (ColorUtil.alpha(text[y][x]) > 0) out[y][x] = text[y][x];
return out;
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
index cea9317..065b923 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/ObjectState.java
@@ -4,10 +4,9 @@ import java.util.List;
import org.bukkit.block.data.BlockData;
/**
- * Snapshot of a non-mob object that renders block/item/text content instead of an entity model:
- * block/item/text DISPLAY entities, dropped items, falling blocks, primed TNT and the block inside a
- * minecart, and leash ropes. Captured on the main thread with its complete local→world transform already resolved
- * (entity position, yaw/pitch or billboard facing, display transformation), so baking is pure geometry.
+ * Snapshot of a non-mob object that renders block/item/text content instead of an entity model: block/item/text
+ * display entities, dropped items, falling blocks, primed TNT, minecart blocks and leash ropes. The local→world
+ * transform is resolved at capture (position, yaw/pitch or billboard, display transformation).
*
* Local frames: BLOCK = the block cube {@code [0,1]³}; ITEM = an item centred on the origin
* ({@code [-0.5,0.5]³}); TEXT = font pixels (x right, y up, text front facing +Z), already scaled.
@@ -19,7 +18,7 @@ public record ObjectState(
BlockEntityState blockEntity, // BLOCK: set when the block is block-entity rendered (chest, banner, …)
ItemSpec item, // ITEM: the item
String context, // ITEM: display context ("none", "fixed", "ground", "head", …)
- boolean dropped, // ITEM: a dropped item entity (vanilla lifts it by 0.1 + 0.25·groundScaleY)
+ boolean dropped, // ITEM: a dropped item entity (lifted above the ground by the baker)
double[] leash, // LEASH: rope points {fromX,fromY,fromZ, toX,toY,toZ} (entity -> holder)
List lines, // TEXT: explicit lines (wrapped by the baker to lineWidth)
int lineWidth, // TEXT: wrap width in font px
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/RenderedEntity.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/RenderedEntity.java
index 5b4b825..d1d92a9 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/RenderedEntity.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/RenderedEntity.java
@@ -2,19 +2,18 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import java.util.List;
-/** A baked entity: its world-space cubes and overall AABB (for broad-phase culling). */
+/** A baked entity: its world-space cubes and their union AABB (framing in the test harnesses). */
public final class RenderedEntity {
public final List cubes;
public final double[] aabbMin;
public final double[] aabbMax;
- public RenderedEntity(List cubes, double[] aabbMin, double[] aabbMax) {
+ private RenderedEntity(List cubes, double[] aabbMin, double[] aabbMax) {
this.cubes = cubes;
this.aabbMin = aabbMin;
this.aabbMax = aabbMax;
}
- /** Wraps baked world-space cubes, computing the overall broad-phase AABB from their per-cube AABBs. */
public static RenderedEntity of(List cubes) {
double[] min = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
double[] max = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java
index 40f751d..0529f11 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java
@@ -1,66 +1,55 @@
package eu.mhsl.minecraft.pixelpics.render.entity;
-/**
- * In-place ARGB pixel-grid compositing shared by the entity/block-entity bakers: deep copy, tint and
- * src-over alpha overlay. Grids are indexed {@code [y][x]}; all operations assume the ARGB layout used
- * throughout the renderer.
- */
+import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
+
+/** In-place ARGB pixel-grid ({@code [y][x]}) compositing shared by the entity and block-entity bakers. */
public final class TextureOps {
private TextureOps() {}
- /** A row-by-row copy so callers can tint/overlay without mutating the cached source texture. */
+ /** A row-by-row copy, so callers can tint/overlay without mutating a cached source texture. */
public static int[][] deepCopy(int[][] src) {
int[][] out = new int[src.length][];
for (int y = 0; y < src.length; y++) out[y] = src[y].clone();
return out;
}
- /** Multiplies every non-transparent texel by an ARGB tint (RGB channels only), in place. */
+ /** Multiplies the RGB of every non-transparent texel by {@code argb}, in place. */
public static void tint(int[][] tex, int argb) {
- int tr = (argb >> 16) & 0xFF, tg = (argb >> 8) & 0xFF, tb = argb & 0xFF;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
- int p = row[x];
- int a = (p >>> 24) & 0xFF;
- if (a == 0) continue;
- int r = ((p >> 16) & 0xFF) * tr / 255, g = ((p >> 8) & 0xFF) * tg / 255, b = (p & 0xFF) * tb / 255;
- row[x] = (a << 24) | (r << 16) | (g << 8) | b;
+ if (ColorUtil.alpha(row[x]) != 0) row[x] = ColorUtil.multiply(row[x], argb);
}
}
}
- /** Standard src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
+ /** Src-over alpha composite of {@code src} onto {@code dst} (clipped to the overlap), in place. */
public static void overlay(int[][] dst, int[][] src) {
int h = Math.min(dst.length, src.length);
for (int y = 0; y < h; y++) {
int w = Math.min(dst[y].length, src[y].length);
for (int x = 0; x < w; x++) {
int sp = src[y][x];
- int sa = (sp >>> 24) & 0xFF;
+ int sa = ColorUtil.alpha(sp);
if (sa == 0) continue;
if (sa == 255) {
dst[y][x] = sp;
continue;
}
int dp = dst[y][x];
- int da = (dp >>> 24) & 0xFF;
+ int da = ColorUtil.alpha(dp);
int outA = sa + da * (255 - sa) / 255;
- int sr = (sp >> 16) & 0xFF, sg = (sp >> 8) & 0xFF, sb = sp & 0xFF;
- int dr = (dp >> 16) & 0xFF, dg = (dp >> 8) & 0xFF, db = dp & 0xFF;
- int r = (sr * sa + dr * da * (255 - sa) / 255) / Math.max(1, outA);
- int g = (sg * sa + dg * da * (255 - sa) / 255) / Math.max(1, outA);
- int b = (sb * sa + db * da * (255 - sa) / 255) / Math.max(1, outA);
- dst[y][x] = (outA << 24) | (r << 16) | (g << 8) | b;
+ int r = (ColorUtil.red(sp) * sa + ColorUtil.red(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
+ int g = (ColorUtil.green(sp) * sa + ColorUtil.green(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
+ int b = (ColorUtil.blue(sp) * sa + ColorUtil.blue(dp) * da * (255 - sa) / 255) / Math.max(1, outA);
+ dst[y][x] = ColorUtil.argb(outA, r, g, b);
}
}
}
/**
- * Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
- * to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
- * replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
- * trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
+ * Armor-trim palette swap: each non-transparent texel is replaced by the {@code to} colour whose index is the
+ * nearest (RGB distance) {@code from} key shade, keeping its alpha. Vanilla trim patterns use exactly those keys.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
@@ -68,13 +57,14 @@ public final class TextureOps {
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
- int a = (p >>> 24) & 0xFF;
+ int a = ColorUtil.alpha(p);
if (a == 0) continue;
- int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
- int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
+ int dr = ColorUtil.red(p) - ColorUtil.red(c);
+ int dg = ColorUtil.green(p) - ColorUtil.green(c);
+ int db = ColorUtil.blue(p) - ColorUtil.blue(c);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) {
bestD = d;
@@ -87,34 +77,27 @@ public final class TextureOps {
}
/**
- * Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
- * texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
- * frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
- * transparent background). Not animated; just a frozen purple shimmer.
+ * Static approximation of the animated enchantment glint: one glint frame tiled over the texture and added
+ * (tinted by {@code glintColor}, scaled by {@code strength}, frame alpha and luminance) to its opaque texels.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
- int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
+ int cr = ColorUtil.red(glintColor), cg = ColorUtil.green(glintColor), cb = ColorUtil.blue(glintColor);
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
- int a = (p >>> 24) & 0xFF;
- if (a == 0) continue; // only sheen actual armor pixels
+ int a = ColorUtil.alpha(p);
+ if (a == 0) continue;
int gp = glint[y % gh][x % gw];
- int ga = (gp >>> 24) & 0xFF;
- int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
- double inten = strength * (ga / 255.0) * (gl / 255.0);
+ int gl = Math.max(ColorUtil.red(gp), Math.max(ColorUtil.green(gp), ColorUtil.blue(gp)));
+ double inten = strength * (ColorUtil.alpha(gp) / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
- int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
- int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
- int b = clamp((p & 0xFF) + (int) (cb * inten));
- tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
+ int r = ColorUtil.clamp(ColorUtil.red(p) + (int) (cr * inten));
+ int g = ColorUtil.clamp(ColorUtil.green(p) + (int) (cg * inten));
+ int b = ColorUtil.clamp(ColorUtil.blue(p) + (int) (cb * inten));
+ tex[y][x] = ColorUtil.argb(a, r, g, b);
}
}
}
-
- private static int clamp(int v) {
- return v < 0 ? 0 : (Math.min(v, 255));
- }
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java
index 78f0441..19484bf 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java
@@ -95,11 +95,11 @@ public final class ElementIntersector {
// Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid
// transform), so faces and UVs stay bound to the model like vanilla's FaceBakery.
e = element.unrotated;
- unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
+ Element.unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
eox = v[0];
eoy = v[1];
eoz = v[2];
- unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
+ Element.unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
edx = v[0];
edy = v[1];
edz = v[2];
@@ -108,7 +108,7 @@ public final class ElementIntersector {
? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
: intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
if (hit && element.unrotated != null) {
- rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
+ Element.rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
@@ -250,7 +250,7 @@ public final class ElementIntersector {
double py = o[1] + d[1] * tEntry;
double pz = o[2] + d[2] * tEntry;
- Direction dir = faceFor(axis, negFace);
+ Direction dir = Direction.of(axis, negFace);
setNormal(sc, obb, dir.nx, dir.ny, dir.nz);
double fracX = frac(px, from[0], to[0]);
double fracY = frac(py, from[1], to[1]);
@@ -274,26 +274,16 @@ public final class ElementIntersector {
double[] o = sc.o, d = sc.d;
double best = Double.POSITIVE_INFINITY;
Direction bestDir = null;
- double bestSign = 0;
for (Direction dir : DIRECTIONS) {
- int a = dir.nx != 0 ? 0 : dir.ny != 0 ? 1 : 2;
- boolean neg = dir.nx + dir.ny + dir.nz < 0;
- int b = (a + 1) % 3, c = (a + 2) % 3;
- int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
- double sign = (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1);
- if (sign * d[a] >= 0) continue; // back face (or parallel)
- double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
+ double t = invertedFaceT(dir, from, to, o, d, false);
if (!(t >= EPS) || t >= best) continue;
- double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
- if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) continue;
- if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) continue;
best = t;
bestDir = dir;
- bestSign = sign;
}
if (bestDir == null) return false;
- int a = bestDir.nx != 0 ? 0 : bestDir.ny != 0 ? 1 : 2;
- setNormal(sc, obb, a == 0 ? bestSign : 0, a == 1 ? bestSign : 0, a == 2 ? bestSign : 0);
+ int a = bestDir.axis;
+ double sign = invertedFaceSign(bestDir, from, to, false);
+ setNormal(sc, obb, a == 0 ? sign : 0, a == 1 ? sign : 0, a == 2 ? sign : 0);
double fx = frac(o[0] + d[0] * best, from[0], to[0]);
double fy = frac(o[1] + d[1] * best, from[1], to[1]);
double fz = frac(o[2] + d[2] * best, from[2], to[2]);
@@ -304,6 +294,34 @@ public final class ElementIntersector {
return true;
}
+ /**
+ * Outward normal sign of face {@code dir} of an inverted box along its axis: each inverted OTHER axis flips the
+ * winding, as does a mirroring transform ({@code mirrored}).
+ */
+ private static double invertedFaceSign(Direction dir, double[] from, double[] to, boolean mirrored) {
+ int b = (dir.axis + 1) % 3, c = (dir.axis + 2) % 3;
+ int flips = (from[b] > to[b] ? 1 : 0) + (from[c] > to[c] ? 1 : 0);
+ boolean neg = dir.nx + dir.ny + dir.nz < 0;
+ return (neg ? -1 : 1) * (flips % 2 == 0 ? 1 : -1) * (mirrored ? -1 : 1);
+ }
+
+ /**
+ * Ray parameter where the ray {@code o + t·d} meets face {@code dir} of an inverted box (see
+ * {@link #invertedBox}) from its visible side inside the face rectangle; NaN for a back face, a parallel ray or
+ * a miss. Shared with the entity intersector (inverted elements baked as items/displays).
+ */
+ public static double invertedFaceT(
+ Direction dir, double[] from, double[] to, double[] o, double[] d, boolean mirrored) {
+ int a = dir.axis, b = (a + 1) % 3, c = (a + 2) % 3;
+ if (invertedFaceSign(dir, from, to, mirrored) * d[a] >= 0) return Double.NaN;
+ boolean neg = dir.nx + dir.ny + dir.nz < 0;
+ double t = ((neg ? from[a] : to[a]) - o[a]) / d[a];
+ double pb = o[b] + d[b] * t, pc = o[c] + d[c] * t;
+ if (pb < Math.min(from[b], to[b]) - EPS || pb > Math.max(from[b], to[b]) + EPS) return Double.NaN;
+ if (pc < Math.min(from[c], to[c]) - EPS || pc > Math.max(from[c], to[c]) + EPS) return Double.NaN;
+ return t;
+ }
+
/**
* The hit normal (local axis normal of the element's frame) in block space: rotated back for {@code obb}. The
* rescale is diagonal in the local frame, so an axis normal keeps its direction (n = R·n_local).
@@ -330,7 +348,7 @@ public final class ElementIntersector {
}
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */
- private static double frac(double v, double from, double to) {
+ public static double frac(double v, double from, double to) {
double span = to - from;
if (Math.abs(span) < 1e-6) return 0;
double f = (v - from) / span;
@@ -360,14 +378,6 @@ public final class ElementIntersector {
};
}
- private static Direction faceFor(int axis, boolean negFace) {
- return switch (axis) {
- case 0 -> negFace ? Direction.WEST : Direction.EAST;
- case 1 -> negFace ? Direction.DOWN : Direction.UP;
- default -> negFace ? Direction.NORTH : Direction.SOUTH;
- };
- }
-
/** Rotates (x, y, z) about axis 0/1/2 (x/y/z) by the angle with the given cos/sin into out. */
private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) {
switch (axis) {
@@ -388,29 +398,4 @@ public final class ElementIntersector {
}
}
}
-
- /** {@link Element#rotate} into out (same arithmetic, without allocating). */
- private static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
- double c = point ? 0.5 : 0;
- double dx = x - c, dy = y - c, dz = z - c;
- for (int i = 0; i < xSteps; i++) {
- double ny = dz, nz = -dy; // (dy, dz) -> (dz, -dy)
- dy = ny;
- dz = nz;
- }
- for (int i = 0; i < ySteps; i++) {
- double nx = -dz, nz = dx; // (dx, dz) -> (-dz, dx)
- dx = nx;
- dz = nz;
- }
- out[0] = dx + c;
- out[1] = dy + c;
- out[2] = dz + c;
- }
-
- /** {@link Element#unrotate} into out (same arithmetic, without allocating). */
- private static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
- rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
- rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
- }
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
index 172e0bd..812b1d8 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java
@@ -212,7 +212,7 @@ public final class FluidRenderer {
int mask = 0;
if (model.elements.isEmpty()) return 0;
for (Direction d : Direction.values()) {
- int axis = d.nx != 0 ? 0 : d.ny != 0 ? 1 : 2;
+ int axis = d.axis;
int a1 = axis == 0 ? 1 : 0, a2 = axis == 2 ? 1 : 2;
boolean positive = d.nx + d.ny + d.nz > 0;
boolean covered = true;
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
index c3816be..841a7eb 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java
@@ -83,11 +83,11 @@ public final class BiomeClimate {
}
}
- public static final int DEFAULT_WATER = 0x3F76E4;
+ private static final int DEFAULT_WATER = 0x3F76E4;
// Vanilla 26.3 EnvironmentAttributes defaults (no dimension type sets them).
- public static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
- public static final double DEFAULT_WATER_FOG_START = -8;
- public static final double DEFAULT_WATER_FOG_END = 96;
+ private static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
+ private static final double DEFAULT_WATER_FOG_START = -8;
+ private static final double DEFAULT_WATER_FOG_END = 96;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
@@ -189,7 +189,7 @@ public final class BiomeClimate {
public static Climate forKey(String biomePath) {
Optional loaded = LOADED.computeIfAbsent(biomePath, BiomeClimate::loadFromServerData);
- return loaded.orElseGet(() -> FALLBACK.getOrDefault(biomePath, DEFAULT));
+ return loaded.isPresent() ? loaded.get() : FALLBACK.getOrDefault(biomePath, DEFAULT);
}
private static Optional loadFromServerData(String biomePath) {
@@ -247,8 +247,8 @@ public final class BiomeClimate {
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
- private static int color(JsonObject effects, String key, int fallback) {
- JsonElement el = effects.get(key);
+ private static int color(JsonObject obj, String key, int fallback) {
+ JsonElement el = obj.get(key);
if (el == null || !el.isJsonPrimitive()) return fallback;
if (el.getAsJsonPrimitive().isNumber()) return el.getAsInt() & 0xFFFFFF;
String s = el.getAsString().trim();
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java
index d9de4a1..f20107e 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java
@@ -6,10 +6,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.block.Biome;
-/**
- * Computes per-biome grass/foliage tint colors by sampling the resource pack's colormaps using the
- * vanilla temperature/downfall formula, plus a per-biome water color. Results are cached per biome.
- */
+/** Per-biome grass/foliage/water tints: explicit biome colours or the pack's colormaps, cached per biome. */
public final class BiomeTintProvider {
private final int[][] grassMap;
@@ -29,7 +26,7 @@ public final class BiomeTintProvider {
return cache.computeIfAbsent(keyOf(biome), this::compute);
}
- /** The biome's climate/effects/attribute data (water fog etc.), see {@link BiomeClimate}. */
+ /** The biome's climate/effects/attribute data (water fog etc.). */
public BiomeClimate.Climate climate(Biome biome) {
return BiomeClimate.forKey(keyOf(biome));
}
@@ -44,18 +41,11 @@ public final class BiomeTintProvider {
private BiomeTint compute(String key) {
BiomeClimate.Climate climate = BiomeClimate.forKey(key);
- // Explicit biome colors (biome "effects") win over the colormap, like in vanilla.
- int grass = climate.grass() != BiomeClimate.NONE
- ? 0xFF000000 | climate.grass()
- : sample(grassMap, climate.temperature(), climate.downfall(), 0xFF91BD59);
- int foliage = climate.foliage() != BiomeClimate.NONE
- ? 0xFF000000 | climate.foliage()
- : sample(foliageMap, climate.temperature(), climate.downfall(), 0xFF77AB2F);
- int dry = climate.dryFoliage() != BiomeClimate.NONE
- ? 0xFF000000 | climate.dryFoliage()
- : sample(dryFoliageMap, climate.temperature(), climate.downfall(), 0xFFA9A05B);
+ int grass = channel(climate.grass(), grassMap, climate, 0xFF91BD59);
+ int foliage = channel(climate.foliage(), foliageMap, climate, 0xFF77AB2F);
+ int dry = channel(climate.dryFoliage(), dryFoliageMap, climate, 0xFFA9A05B);
- // Vanilla grass_color_modifier (applies to grass only).
+ // Vanilla grass_color_modifier (grass only).
if ("swamp".equals(climate.grassModifier())) {
// Vanilla picks 0x4C763C/0x6A7039 via position noise; use the dominant color.
grass = 0xFF6A7039;
@@ -65,11 +55,16 @@ public final class BiomeTintProvider {
return new BiomeTint(grass, foliage, dry, 0xFF000000 | climate.water());
}
+ /** An explicit biome colour wins over the colormap, like in vanilla. */
+ private static int channel(int explicit, int[][] colormap, BiomeClimate.Climate climate, int fallback) {
+ return explicit != BiomeClimate.NONE ? 0xFF000000 | explicit : sample(colormap, climate, fallback);
+ }
+
/** Vanilla colormap lookup: x = (1-temp)*255, y = (1-downfall*temp)*255. */
- private int sample(int[][] colormap, double temperature, double downfall, int fallback) {
+ private static int sample(int[][] colormap, BiomeClimate.Climate climate, int fallback) {
if (colormap == null || colormap.length == 0) return fallback;
- double temp = Math.clamp(temperature, 0, 1);
- double down = Math.clamp(downfall, 0, 1) * temp;
+ double temp = Math.clamp(climate.temperature(), 0, 1);
+ double down = Math.clamp(climate.downfall(), 0, 1) * temp;
int x = (int) ((1.0 - temp) * 255.0);
int y = (int) ((1.0 - down) * 255.0);
int h = colormap.length;
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java
index d94c61f..37258b2 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/TintResolver.java
@@ -1,11 +1,11 @@
package eu.mhsl.minecraft.pixelpics.render.tint;
+import java.util.Locale;
+import org.bukkit.Material;
import org.bukkit.block.data.BlockData;
+import org.bukkit.block.data.type.RedstoneWire;
-/**
- * Maps a tinted face (material + tintindex) to the concrete tint color, choosing between the
- * biome-driven channels and a handful of vanilla constants.
- */
+/** Maps a tinted face (material + tintindex) to its tint colour: a biome channel or a vanilla constant. */
public final class TintResolver {
private static final int BIRCH = 0xFF80A755;
@@ -13,51 +13,56 @@ public final class TintResolver {
private static final int LILY_PAD = 0xFF208030;
private static final int STEM = 0xFF60A017;
+ private enum Channel {
+ BIRCH,
+ SPRUCE,
+ FOLIAGE,
+ LILY_PAD,
+ WATER,
+ REDSTONE,
+ STEM,
+ GRASS
+ }
+
+ // Per Material.ordinal(), filled lazily (idempotent, so the benign race is harmless); called for every tinted hit.
+ private static final Channel[] CHANNELS = new Channel[Material.values().length];
+
private TintResolver() {}
/** Returns the ARGB tint to multiply with, or {@code -1} when the face should not be tinted. */
public static int resolve(BlockData data, int tintIndex, BiomeTint biomeTint) {
if (tintIndex < 0) return -1;
- String name = data.getMaterial().name().toLowerCase();
-
- if (name.equals("birch_leaves")) return BIRCH;
- if (name.equals("spruce_leaves")) return SPRUCE;
- if (name.endsWith("leaves") || name.equals("vine")) return biomeTint.foliage();
- if (name.equals("lily_pad")) return LILY_PAD;
-
- if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) {
- return biomeTint.water();
- }
- if (name.equals("redstone_wire")) return redstone(data);
- if (name.endsWith("stem")) return STEM;
-
- // grass_block (top/overlay), short_grass, tall_grass, fern, large_fern, sugar_cane, ...
- if (name.contains("grass")
- || name.equals("fern")
- || name.equals("large_fern")
- || name.equals("sugar_cane")
- || name.equals("potted_fern")) {
- return biomeTint.grass();
- }
-
- // Default for unknown tinted faces: grass channel (the most common tintindex 0 use).
- return biomeTint.grass();
+ Material material = data.getMaterial();
+ Channel channel = CHANNELS[material.ordinal()];
+ if (channel == null)
+ CHANNELS[material.ordinal()] = channel = channel(material.name().toLowerCase(Locale.ROOT));
+ return switch (channel) {
+ case BIRCH -> BIRCH;
+ case SPRUCE -> SPRUCE;
+ case FOLIAGE -> biomeTint.foliage();
+ case LILY_PAD -> LILY_PAD;
+ case WATER -> biomeTint.water();
+ case REDSTONE -> redstone(data);
+ case STEM -> STEM;
+ case GRASS -> biomeTint.grass();
+ };
}
+ private static Channel channel(String name) {
+ if (name.equals("birch_leaves")) return Channel.BIRCH;
+ if (name.equals("spruce_leaves")) return Channel.SPRUCE;
+ if (name.endsWith("leaves") || name.equals("vine")) return Channel.FOLIAGE;
+ if (name.equals("lily_pad")) return Channel.LILY_PAD;
+ if (name.equals("water") || name.equals("water_cauldron") || name.equals("bubble_column")) return Channel.WATER;
+ if (name.equals("redstone_wire")) return Channel.REDSTONE;
+ if (name.endsWith("stem")) return Channel.STEM;
+ // grass_block, short/tall grass, ferns, sugar cane, … and any other tinted face (tintindex 0 is mostly grass).
+ return Channel.GRASS;
+ }
+
+ /** Approximation of the redstone wire colour: red rising with the power level. */
private static int redstone(BlockData data) {
- int power = 0;
- String s = data.getAsString(false);
- int idx = s.indexOf("power=");
- if (idx >= 0) {
- int end = idx + 6;
- int e = end;
- while (e < s.length() && Character.isDigit(s.charAt(e))) e++;
- try {
- power = Integer.parseInt(s.substring(end, e));
- } catch (NumberFormatException ignored) {
- }
- }
- int r = Math.min(255, 75 + power * 12);
- return 0xFF000000 | (r << 16);
+ int power = data instanceof RedstoneWire wire ? wire.getPower() : 0;
+ return 0xFF000000 | (Math.min(255, 75 + power * 12) << 16);
}
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java
index 49c2fec..55435a2 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java
@@ -3,9 +3,7 @@ package eu.mhsl.minecraft.pixelpics.render.util;
import org.bukkit.Color;
import org.bukkit.DyeColor;
-/**
- * Helpers for packed ARGB integer colors.
- */
+/** Helpers for packed ARGB colours. */
public final class ColorUtil {
private ColorUtil() {}
@@ -37,11 +35,8 @@ public final class ColorUtil {
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
}
- /**
- * The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()}
- * table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
- */
- public static int signTextColor(DyeColor dye) {
+ /** Vanilla {@code DyeColor.getTextColor()} (not the cloth colour) as opaque ARGB; null = black (default ink). */
+ private static int signTextColor(DyeColor dye) {
int rgb =
switch (dye == null ? DyeColor.BLACK : dye) {
case WHITE -> 0xF9FFFE;
@@ -64,23 +59,16 @@ public final class ColorUtil {
return 0xFF000000 | rgb;
}
- /**
- * The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye
- * colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim).
- */
+ /** Sign text fill (vanilla SignRenderer): the dye's text colour, darkened to 40 % unless glowing. */
public static int signFillArgb(DyeColor dye, boolean glowing) {
int base = signTextColor(dye);
- return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF));
+ return glowing ? base : shade(base, 0.4);
}
- /**
- * The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}):
- * the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it
- * stays readable.
- */
+ /** Outline of glowing sign text (vanilla {@code getDarkColor}): text colour at 40 %, black ink a light cream. */
public static int signOutlineArgb(DyeColor dye) {
- if ((dye == null ? DyeColor.BLACK : dye) == DyeColor.BLACK) return 0xFFF0EBCC;
- return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF);
+ if (dye == null || dye == DyeColor.BLACK) return 0xFFF0EBCC;
+ return shade(signTextColor(dye), 0.4);
}
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
@@ -100,7 +88,7 @@ public final class ColorUtil {
return alpha(argb) > ALPHA_CUTOUT;
}
- /** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */
+ /** Scales the RGB channels by {@code factor}, keeping alpha. */
public static int shade(int argb, double factor) {
int a = alpha(argb);
int r = clamp((int) (red(argb) * factor));
@@ -116,14 +104,12 @@ public final class ColorUtil {
| clamp(blue(dst) + blue(src));
}
- private static int clamp(int v) {
+ /** Clamps a channel value to 0..255. */
+ public static int clamp(int v) {
return v < 0 ? 0 : Math.min(v, 255);
}
- /**
- * Normalized weighted average of two RGB colors (alpha ignored, result opaque-RGB in the low 24 bits).
- * Weights need not sum to 1; they are normalized by their total.
- */
+ /** Weighted average of two RGB colours (weights normalised by their sum); alpha ignored, result RGB only. */
public static int mix(int rgbA, int rgbB, double weightA, double weightB) {
double total = weightA + weightB;
int r = (int) ((red(rgbA) * weightA + red(rgbB) * weightB) / total);
@@ -132,8 +118,6 @@ public final class ColorUtil {
return (r << 16) | (g << 8) | b;
}
- // --- Gamma-correct (linear-light) averaging ---
-
private static final float[] SRGB_TO_LINEAR = new float[256];
static {
@@ -147,7 +131,7 @@ public final class ColorUtil {
return SRGB_TO_LINEAR[channel & 0xFF];
}
- public static int toSrgb(double linear) {
+ private static int toSrgb(double linear) {
double c = linear <= 0.0031308 ? linear * 12.92 : 1.055 * Math.pow(linear, 1 / 2.4) - 0.055;
return clamp((int) Math.round(c * 255.0));
}
diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/MathUtil.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/MathUtil.java
index c7bdd3c..b69c6fa 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/MathUtil.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/MathUtil.java
@@ -1,9 +1,8 @@
package eu.mhsl.minecraft.pixelpics.render.util;
-import org.bukkit.block.BlockFace;
import org.bukkit.util.Vector;
-public class MathUtil {
+public final class MathUtil {
private MathUtil() {}
@@ -11,8 +10,4 @@ public class MathUtil {
public static Vector reflectVector(Vector direction, Vector normal) {
return direction.clone().subtract(normal.clone().multiply(2 * direction.dot(normal)));
}
-
- public static Vector toVector(BlockFace face) {
- return new Vector(face.getModX(), face.getModY(), face.getModZ());
- }
}