From a0b7ec3a90d2e98be1f3c53f71a4b27e2ed48abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 14:11:40 +0100 Subject: [PATCH] apply vanilla uvlock under blockstate x/y rotations and face zero-thickness elements towards the ray x rotations without uvlock already sampled the model-space element like vanilla (lying logs/pillars, vertical observers/dispensers verified against FaceBakery for every blockstate variant). The remaining divergence was uvlock: faces kept model-extent UVs and were sampled in world orientation, so top stairs, ceiling buttons, fences/walls and ceiling vines showed shifted or rotated textures. Rotated uvlocked elements now also go through the model-space path, with each face's final UVs mapped by vanilla's BlockMath face transformation (inverted, as in FaceBakery). Zero-thickness elements (vine, lichen) always reported their low face; pick the face turned towards the ray instead. Edge-case test plugin: xrot/xrot2 scenes (lying pillars, vertical observers/dispensers/droppers, top stairs, ceiling buttons). --- .../pixelpics/assets/ModelBaker.java | 54 +++++++++++++++++-- .../pixelpics/assets/model/Element.java | 7 +-- .../pixelpics/assets/model/Face.java | 25 +++++++++ .../render/entity/cem/ItemGeometry.java | 6 +-- .../render/raytrace/ElementIntersector.java | 9 ++-- tools/edgecase-plugin/pptest/PPTest.java | 35 ++++++++++++ 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java index a6fb9f2..5d486b2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java @@ -96,9 +96,13 @@ public final class ModelBaker { } } - // Without uvlock, textures turn with the model (vanilla): keep the model-space element so the - // intersectors can sample it exactly. With uvlock they stay world-aligned (the rotated faces). - Element unrotated = (!variant.uvlock() && (xSteps != 0 || ySteps != 0)) ? modelElement : null; + // Rotated variants keep the model-space element; intersectors hit it with the inverse-rotated ray + // (vanilla bakes the same way). Without uvlock the textures thus turn with the model; with uvlock + // each face's final UVs are additionally transformed so the texture stays world-aligned. + Element unrotated = null; + if (xSteps != 0 || ySteps != 0) { + unrotated = variant.uvlock() ? uvLocked(modelElement, xSteps, ySteps) : modelElement; + } baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps)); // Accumulate average color from the element's face textures. @@ -149,6 +153,50 @@ public final class ModelBaker { }; } + /** The model-space element with every face's UVs locked to the world under the given rotation. */ + private static Element uvLocked(Element e, int xSteps, int ySteps) { + Face[] locked = new Face[6]; + for (Direction d : Direction.values()) { + Face f = e.faces[d.ordinal()]; + if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps)); + } + return new Element(e.from, e.to, locked, e.rotOrigin, e.rotAxis, e.rotAngleRad, e.rescale); + } + + /** + * Vanilla uvlock ({@code BlockMath.getFaceTransformation}, inverted as in {@code FaceBakery}): the final + * texture coordinates of model face {@code d}, centred on the texture, are mapped by + * {@code M = G2L(d) · R⁻¹ · L2G(D)} where R is the blockstate rotation, D the world face {@code d} turns into, + * and L2G the per-face UV frames (south = identity). For side faces this keeps the texture world-aligned; + * up/down under x rotations follow vanilla's frames (not strictly world-aligned). M is one of the 8 symmetries + * of the unit UV square, returned as the affine of {@link Face#uvLock}. + */ + static double[] uvLockTransform(Direction d, int xSteps, int ySteps) { + Direction world = d.rotateX(xSteps).rotateY(ySteps); + double[] cu = lockAxis(d, world, 1, 0, xSteps, ySteps); + double[] cv = lockAxis(d, world, 0, 1, xSteps, ySteps); + // u' = ½ + cu.x (u-½) + cv.x (v-½), v' = ½ + cu.y (u-½) + cv.y (v-½) + return new double[] {0.5 - 0.5 * (cu[0] + cv[0]), cu[0], cv[0], 0.5 - 0.5 * (cu[1] + cv[1]), cu[1], cv[1]}; + } + + private static double[] lockAxis(Direction d, Direction world, double u, double v, int xSteps, int ySteps) { + double[] w = uvFrame(world, u, v, 0, false); + double[] m = Element.unrotate(w[0], w[1], w[2], xSteps, ySteps, false); + return uvFrame(d, m[0], m[1], m[2], true); + } + + /** Vanilla {@code BlockMath.VANILLA_UV_TRANSFORM_LOCAL_TO_GLOBAL} (or its inverse) applied to a vector. */ + private static double[] uvFrame(Direction d, double x, double y, double z, boolean inverse) { + return switch (d) { + case SOUTH -> new double[] {x, y, z}; + case NORTH -> new double[] {-x, y, -z}; // rotY(180°) + case EAST -> inverse ? new double[] {-z, y, x} : new double[] {z, y, -x}; // rotY(90°) + case WEST -> inverse ? new double[] {z, y, -x} : new double[] {-z, y, x}; // rotY(-90°) + case UP -> inverse ? new double[] {x, -z, y} : new double[] {x, z, -y}; // rotX(-90°) + case DOWN -> inverse ? new double[] {x, z, -y} : new double[] {x, -z, y}; // rotX(90°) + }; + } + private int[][] resolveTexture(String ref, Map vars) { if (ref == null) return null; String current = ref; 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 3f0c06d..6a57698 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 @@ -19,9 +19,10 @@ public final class Element { public final double rotAngleRad; // radians public final boolean rescale; - // Blockstate rotation WITHOUT uvlock: the model-space element and the 90° steps (x first, then y) that - // turn it into this world-space one. Textures stay bound to the model, so intersectors hit the - // unrotated element with the inverse-rotated ray. Null/0 when unrotated or uvlocked. + // Blockstate rotation: the model-space element and the 90° steps (x first, then y) that turn it into this + // world-space one. Intersectors hit the unrotated element with the inverse-rotated ray, so textures stay + // bound to the model (vanilla); under uvlock its faces carry a UV transform keeping them world-aligned. + // Null/0 when unrotated. public final Element unrotated; public final int xSteps; public final int ySteps; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java index be50fe2..cca086f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/model/Face.java @@ -11,7 +11,17 @@ public final class Face { public final int rotation; // 0/90/180/270, applied to the sampled UV public final int tintIndex; // -1 = no tint + // uvlock: affine applied to the final texture (u, v) as {a, b, c, d, e, f} with u' = a + b*u + c*v and + // v' = d + e*u + f*v (one of the 8 symmetries of the unit square), null when not locked. + public final double[] uvLock; + public Face(int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex) { + this(texture, u1, v1, u2, v2, rotation, tintIndex, null); + } + + public Face( + int[][] texture, double u1, double v1, double u2, double v2, int rotation, int tintIndex, double[] uvLock) { + this.uvLock = uvLock; this.texture = texture; this.u1 = u1; this.v1 = v1; @@ -21,6 +31,16 @@ public final class Face { this.tintIndex = tintIndex; } + /** This face with another texture and tint index (UVs, rotation and uvlock kept). */ + public Face withTexture(int[][] texture, int tintIndex) { + return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, uvLock); + } + + /** This face with its final texture coordinates transformed by a uvlock affine (see {@link #uvLock}). */ + public Face withUvLock(double[] lock) { + return new Face(texture, u1, v1, u2, v2, rotation, tintIndex, lock); + } + /** * Samples the ARGB pixel for a position within the face, where {@code (s, t)} are in 0..1 across * the face's two in-plane axes (s = horizontal, t = vertical, top-left origin). @@ -48,6 +68,11 @@ public final class Face { double u = u1 + (u2 - u1) * rs; double v = v1 + (v2 - v1) * rt; + if (uvLock != null) { + double lu = uvLock[0] + uvLock[1] * u + uvLock[2] * v; + v = uvLock[3] + uvLock[4] * u + uvLock[5] * v; + u = lu; + } int h = texture.length; if (h == 0) return 0; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java index 6127950..d6544f2 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/ItemGeometry.java @@ -110,8 +110,8 @@ public final class ItemGeometry { ResolvedModel model = registry.get(data); if (model == null || !model.hasGeometry) return; for (Element world : model.elements) { - // Rotated without uvlock: bake the model-space element turned by the blockstate rotation, so - // textures stay bound to the model (vanilla); otherwise the world-space element as is. + // Rotated: bake the model-space element turned by the blockstate rotation, so textures stay bound + // to the model like vanilla (uvlocked faces carry their UV transform); otherwise the element as is. Element e = world.unrotated != null ? world.unrotated : world; Affine variant = world.unrotated != null ? variantRotation(world.xSteps, world.ySteps) : Affine.identity(); Face[] faces = new Face[6]; @@ -468,6 +468,6 @@ public final class ItemGeometry { return copy; }); } - return new Face(t, f.u1, f.v1, f.u2, f.v2, f.rotation, -1); + return f.withTexture(t, -1); } } 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 0d3209f..7033452 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 @@ -45,8 +45,8 @@ public final class ElementIntersector { Element element = model.elements.get(i); Candidate c; if (element.unrotated != null) { - // Blockstate rotation without uvlock: hit the model-space element with the inverse-rotated ray - // (same t: rigid transform), so faces and UVs stay bound to the model like in vanilla. + // 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 in vanilla (uvlock: see Face#uvLock). Element m = element.unrotated; double[] mo = Element.unrotate(ox, oy, oz, element.xSteps, element.ySteps, true); double[] md = Element.unrotate(dx, dy, dz, element.xSteps, element.ySteps, false); @@ -130,12 +130,13 @@ public final class ElementIntersector { double inv = 1.0 / d[a]; double t1 = (from[a] - o[a]) * inv; double t2 = (to[a] - o[a]) * inv; - boolean neg = true; + // Entered through the low face when travelling +axis. Decided by the direction, not t1 > t2, so + // zero-thickness elements (vines, lichen: from == to) show the face turned towards the ray. + boolean neg = inv > 0; if (t1 > t2) { double tmp = t1; t1 = t2; t2 = tmp; - neg = false; } if (t1 > tmin) { tmin = t1; diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 7c85f08..82c354b 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -56,6 +56,9 @@ public class PPTest extends JavaPlugin { scenes.put("pistondown", new Scene("pistondown", this::piston, -1.6, 2.6, -1.6, 0.5, 1.4, 0.5)); scenes.put("pistonup", new Scene("pistonup", this::piston, 0.4, 2.6, -1.6, 2.5, 1.4, 0.5)); scenes.put("vehicles", new Scene("vehicles", this::vehicles, 5, 3, -7, 5, 0.6, 1.5)); + // Blockstate x rotations (+ x/y combined, uvlock): lying pillars, vertical observers/dispensers, top stairs. + scenes.put("xrot", new Scene("xrot", this::xrot, 3.5, 5.5, -4.5, 6, 0.5, 3.5)); + scenes.put("xrot2", new Scene("xrot2", this::xrot, 11, 5, 10.5, 6, 0.5, 3.5)); // Chests: same layout seen from all four sides (single/double per facing, trapped, ender, copper). scenes.put("chests_s", new Scene("chests_s", this::chests, 4.5, 5, 13, 4.5, 0, 3)); scenes.put("chests_n", new Scene("chests_n", this::chests, 4.5, 5, -7, 4.5, 0, 3)); @@ -491,6 +494,38 @@ public class PPTest extends JavaPlugin { o.clone().add(4, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:furnace[facing=north]"), false); } + /** + * Row z=0: oak log, basalt, quartz pillar, hay bale lying along x (x=90,y=90) and z (x=90). Row z=3: observer, + * dispenser, dropper facing up and down. Row z=6: oak and stone-brick stairs half=top in all four facings + * (x=180 + uvlock), oak buttons on the ceiling. A stone column at z=-2 marks north. + */ + private void xrot(Location o) { + java.util.function.BiConsumer put = (p, data) -> o.clone().add(p[0], p[1], p[2]).getBlock() + .setBlockData(Bukkit.createBlockData("minecraft:" + data), false); + String[] pillars = {"oak_log", "basalt", "quartz_pillar", "hay_block"}; + for (int i = 0; i < pillars.length; i++) { + put.accept(new int[] {i * 3, 0, 0}, pillars[i] + "[axis=x]"); + put.accept(new int[] {i * 3 + 1, 0, 0}, pillars[i] + "[axis=z]"); + } + String[] vertical = {"observer", "dispenser", "dropper"}; + for (int i = 0; i < vertical.length; i++) { + put.accept(new int[] {i * 3, 0, 3}, vertical[i] + "[facing=up]"); + put.accept(new int[] {i * 3 + 1, 1, 3}, vertical[i] + "[facing=down]"); + put.accept(new int[] {i * 3 + 1, 0, 3}, "glass"); + } + String[] f = {"north", "east", "south", "west"}; + for (int i = 0; i < f.length; i++) { + put.accept(new int[] {i * 2, 0, 6}, "oak_stairs[facing=" + f[i] + ",half=top]"); + put.accept(new int[] {i * 2 + 1, 0, 6}, "stone_brick_stairs[facing=" + f[i] + ",half=top]"); + } + put.accept(new int[] {9, 1, 6}, "stone"); + put.accept(new int[] {9, 0, 6}, "oak_button[face=ceiling,facing=north]"); + put.accept(new int[] {10, 1, 6}, "stone"); + put.accept(new int[] {10, 0, 6}, "oak_button[face=ceiling,facing=east]"); + put.accept(new int[] {0, 0, -2}, "stone"); + put.accept(new int[] {0, 1, -2}, "stone"); + } + private void vehicles(Location o) { spawn(o, 0, 0, 0, 0, EntityType.CHEST_MINECART); spawn(o, 1.8, 0, 0, 90, EntityType.FURNACE_MINECART);