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()); - } }