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;