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).
This commit is contained in:
MineTec committed 2026-11-04 14:11:40 +01:00
1 parent 5852d4b367
commit a0b7ec3a90
6 files changed
+123 -13

No files matched your search

@@ -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<String, String> vars) {
if (ref == null) return null;
String current = ref;
@@ -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;
@@ -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;
@@ -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);
}
}
@@ -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;
+35
View File
@@ -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<int[], String> 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);