bind block textures to the model under blockstate rotations (vanilla UVs)

- face UV orientation now follows vanilla BlockElement.uvsByFace: north/
  east/down faces were sampled mirrored. Default UVs adjusted to match.
  Block and entity faces share one mapping (ElementIntersector.faceUv),
  so the separate blockUv mode of the entity intersector is gone.
- x/y blockstate rotations without uvlock: elements keep their model-space
  copy and are intersected with the inverse-rotated ray, so textures turn
  with the model exactly like vanilla (pistons facing down showed their
  side textures in different directions per face). Replaces the earlier
  up/down-only UV rotation. Display entities use the same model-space path.

Edge-case test plugin: piston/observer/dispenser scenes.
This commit is contained in:
MineTec committed 2026-11-04 13:36:15 +01:00
1 parent 73e521131b
commit 37fa1d8f56
8 files changed
+139 -95

No files matched your search

@@ -68,6 +68,8 @@ public final class ModelBaker {
rescale = dto.rotation.rescale; rescale = dto.rotation.rescale;
} }
Element modelElement = new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale);
// Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation. // Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation.
for (int i = 0; i < xSteps; i++) { for (int i = 0; i < xSteps; i++) {
double[][] r = rotateBoxX(from, to); double[][] r = rotateBoxX(from, to);
@@ -86,12 +88,6 @@ public final class ModelBaker {
from = r[0]; from = r[0];
to = r[1]; to = r[1];
faces = rotateFacesY(faces); faces = rotateFacesY(faces);
// Without uvlock the top/bottom textures turn with the model (vanilla); side faces already
// keep their orientation through the face re-assignment. With uvlock they stay world-aligned.
if (!variant.uvlock()) {
faces[Direction.UP.ordinal()] = turned(faces[Direction.UP.ordinal()], 90);
faces[Direction.DOWN.ordinal()] = turned(faces[Direction.DOWN.ordinal()], 90);
}
if (rotAxis >= 0) { if (rotAxis >= 0) {
rotOrigin = rotatePointY(rotOrigin); rotOrigin = rotatePointY(rotOrigin);
AxisRotation na = rotateAxisY(rotAxis); AxisRotation na = rotateAxisY(rotAxis);
@@ -100,7 +96,10 @@ public final class ModelBaker {
} }
} }
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale)); // 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;
baked.add(new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps));
// Accumulate average color from the element's face textures. // Accumulate average color from the element's face textures.
for (Face f : faces) { for (Face f : faces) {
@@ -140,9 +139,13 @@ public final class ModelBaker {
*/ */
private double[] defaultUv(Direction dir, double[] f, double[] t) { private double[] defaultUv(Direction dir, double[] f, double[] t) {
return switch (dir) { return switch (dir) {
case UP, DOWN -> new double[] {f[0], f[2], t[0], t[2]}; // Vanilla BlockElement.uvsByFace (u/v in 0..1).
case NORTH, SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]}; case DOWN -> new double[] {f[0], 1 - t[2], t[0], 1 - f[2]};
case WEST, EAST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]}; case UP -> new double[] {f[0], f[2], t[0], t[2]};
case NORTH -> new double[] {1 - t[0], 1 - t[1], 1 - f[0], 1 - f[1]};
case SOUTH -> new double[] {f[0], 1 - t[1], t[0], 1 - f[1]};
case WEST -> new double[] {f[2], 1 - t[1], t[2], 1 - f[1]};
case EAST -> new double[] {1 - t[2], 1 - t[1], 1 - f[2], 1 - f[1]};
}; };
} }
@@ -168,12 +171,6 @@ public final class ModelBaker {
}; };
} }
/** The face with its texture lookup rotated by a further {@code deg} (90° steps). */
private static Face turned(Face f, int deg) {
if (f == null) return null;
return new Face(f.texture, f.u1, f.v1, f.u2, f.v2, (f.rotation + deg) % 360, f.tintIndex);
}
// --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) --- // --- 90-degree box rotations around the block center (0.5, 0.5, 0.5) ---
private double[][] rotateBoxY(double[] from, double[] to) { private double[][] rotateBoxY(double[] from, double[] to) {
@@ -19,6 +19,13 @@ public final class Element {
public final double rotAngleRad; // radians public final double rotAngleRad; // radians
public final boolean rescale; 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.
public final Element unrotated;
public final int xSteps;
public final int ySteps;
public Element( public Element(
double[] from, double[] from,
double[] to, double[] to,
@@ -27,6 +34,23 @@ public final class Element {
int rotAxis, int rotAxis,
double rotAngleRad, double rotAngleRad,
boolean rescale) { boolean rescale) {
this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0);
}
public Element(
double[] from,
double[] to,
Face[] faces,
double[] rotOrigin,
int rotAxis,
double rotAngleRad,
boolean rescale,
Element unrotated,
int xSteps,
int ySteps) {
this.unrotated = unrotated;
this.xSteps = xSteps;
this.ySteps = ySteps;
this.from = from; this.from = from;
this.to = to; this.to = to;
this.faces = faces; this.faces = faces;
@@ -36,6 +60,29 @@ public final class Element {
this.rescale = rescale; this.rescale = rescale;
} }
/** 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 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;
}
return new double[] {dx + c, dy + c, 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);
}
public boolean isAxisAligned() { public boolean isAxisAligned() {
return rotAxis < 0 || rotAngleRad == 0.0; return rotAxis < 0 || rotAngleRad == 0.0;
} }
@@ -16,15 +16,7 @@ public final class EntityCube {
public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE};
public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE};
/** Faces use BLOCK-model UV orientation (resolved block-model faces) instead of entity box-UV. */
public final boolean blockUv;
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) { public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) {
this(from, to, faces, toWorld, false);
}
public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean blockUv) {
this.blockUv = blockUv;
this.from = from; this.from = from;
this.to = to; this.to = to;
this.faces = faces; this.faces = faces;
@@ -2,6 +2,7 @@ package eu.mhsl.minecraft.pixelpics.render.entity;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction; import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face; import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.raytrace.ElementIntersector;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit; import eu.mhsl.minecraft.pixelpics.render.raytrace.FaceHit;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
@@ -85,52 +86,9 @@ public final class EntityIntersector {
double fx = frac(px, cube.from[0], cube.to[0]); double fx = frac(px, cube.from[0], cube.to[0]);
double fy = frac(py, cube.from[1], cube.to[1]); double fy = frac(py, cube.from[1], cube.to[1]);
double fz = frac(pz, cube.from[2], cube.to[2]); double fz = frac(pz, cube.from[2], cube.to[2]);
// (s,t) = Blockbench/Java box-UV (lerp_x, lerp_y) for this face (see BoxUv). Front/right faces // Vanilla face-UV orientation, shared by block-model faces and entity box-UV (see BoxUv).
// run their horizontal axis opposite to back/left (they're viewed from the other side). double[] st = ElementIntersector.faceUv(dir, fx, fy, fz);
double s, tt; double s = st[0], tt = st[1];
if (cube.blockUv) {
// Block-model faces (see ElementIntersector): side faces run along +x / +z, up/down along x,z.
switch (dir) {
case UP, DOWN -> {
s = fx;
tt = fz;
}
case NORTH, SOUTH -> {
s = fx;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
}
}
} else
switch (dir) {
case UP -> {
s = fx;
tt = fz;
}
case DOWN -> {
s = fx;
tt = 1 - fz;
}
case NORTH -> {
s = 1 - fx;
tt = 1 - fy;
}
case SOUTH -> {
s = fx;
tt = 1 - fy;
}
case EAST -> {
s = 1 - fz;
tt = 1 - fy;
}
default -> {
s = fz;
tt = 1 - fy;
} // WEST
}
int color = face.sample(s, tt); int color = face.sample(s, tt);
if (!ColorUtil.opaque(color)) return null; if (!ColorUtil.opaque(color)) return null;
@@ -179,8 +179,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
List<EntityCube> cubes = new ArrayList<>(baked.size()); List<EntityCube> cubes = new ArrayList<>(baked.size());
for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world())));
for (EntityCube c : itemCubes) for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld)));
cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld), c.blockUv));
return RenderedEntity.of(cubes); return RenderedEntity.of(cubes);
} }
@@ -109,10 +109,14 @@ public final class ItemGeometry {
if (data == null) return; if (data == null) return;
ResolvedModel model = registry.get(data); ResolvedModel model = registry.get(data);
if (model == null || !model.hasGeometry) return; if (model == null || !model.hasGeometry) return;
for (Element e : model.elements) { 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.
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]; Face[] faces = new Face[6];
for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data); for (int i = 0; i < 6; i++) faces[i] = tint(e.faces[i], data);
out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(elementTransform(e)), true)); out.add(new EntityCube(e.from, e.to, faces, toWorld.mul(variant).mul(elementTransform(e))));
} }
} }
@@ -121,7 +125,7 @@ public final class ItemGeometry {
if (state == null || blockEntities == null) return; if (state == null || blockEntities == null) return;
RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0) RenderedEntity r = blockEntities.bake(state); // placed at cell (bx,by,bz) = (0,0,0)
if (r == null) return; if (r == null) return;
for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld), c.blockUv)); for (EntityCube c : r.cubes) out.add(new EntityCube(c.from, c.to, c.faces, toWorld.mul(c.toWorld)));
} }
// --- item definition / model resolution -------------------------------------------------------- // --- item definition / model resolution --------------------------------------------------------
@@ -426,6 +430,14 @@ public final class ItemGeometry {
return boxes; return boxes;
} }
/** Vanilla blockstate rotation (x steps then y steps, clockwise, about the block centre); see Element#rotate. */
private static Affine variantRotation(int xSteps, int ySteps) {
return Affine.translation(0.5, 0.5, 0.5)
.mul(Affine.rotY(-Math.PI / 2 * ySteps))
.mul(Affine.rotX(-Math.PI / 2 * xSteps))
.mul(Affine.translation(-0.5, -0.5, -0.5));
}
/** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */ /** Element rotation (origin/axis/angle, optional rescale) as an affine in block units. */
private static Affine elementTransform(Element e) { private static Affine elementTransform(Element e) {
if (e.isAxisAligned()) return Affine.identity(); if (e.isAxisAligned()) return Affine.identity();
@@ -40,9 +40,26 @@ public final class ElementIntersector {
List<Candidate> candidates = new ArrayList<>(model.elements.size()); List<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) { for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i); Element element = model.elements.get(i);
Candidate c = element.isAxisAligned() Candidate c;
? intersectAabb(element, ox, oy, oz, dx, dy, dz) if (element.unrotated != null) {
: intersectObb(element, ox, oy, oz, dx, dy, dz); // 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.
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);
c = m.isAxisAligned()
? intersectAabb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2])
: intersectObb(m, mo[0], mo[1], mo[2], md[0], md[1], md[2]);
if (c != null) {
double[] n = Element.rotate(
c.normal[0], c.normal[1], c.normal[2], element.xSteps, element.ySteps, false);
c = new Candidate(c.element, c.t, c.dir, c.s, c.t2, n, 0);
}
} else {
c = element.isAxisAligned()
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
: intersectObb(element, ox, oy, oz, dx, dy, dz);
}
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i)); if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
} }
if (candidates.isEmpty()) return null; if (candidates.isEmpty()) return null;
@@ -149,26 +166,28 @@ public final class ElementIntersector {
double fracY = frac(py, from[1], to[1]); double fracY = frac(py, from[1], to[1]);
double fracZ = frac(pz, from[2], to[2]); double fracZ = frac(pz, from[2], to[2]);
double s, t; double[] st = faceUv(dir, fracX, fracY, fracZ);
switch (dir) { double s = st[0], t = st[1];
// Texture V is top-down (0 = texture top). For side faces the texture top is the block
// top (high Y), so t = 1 - fracY.
case UP, DOWN -> {
s = fracX;
t = fracZ;
}
case NORTH, SOUTH -> {
s = fracX;
t = 1 - fracY;
}
default -> {
s = fracZ;
t = 1 - fracY;
} // WEST, EAST
}
return new Candidate(e, tEntry, dir, s, t, normal, 0); return new Candidate(e, tEntry, dir, s, t, normal, 0);
} }
/**
* Face-local (s, t) in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's
* {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with these, so north/east
* faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards
* north. Shared with the entity intersector (entity box-UV uses the same orientation).
*/
public static double[] faceUv(Direction dir, double fx, double fy, double fz) {
return switch (dir) {
case UP -> new double[] {fx, fz};
case DOWN -> new double[] {fx, 1 - fz};
case NORTH -> new double[] {1 - fx, 1 - fy};
case SOUTH -> new double[] {fx, 1 - fy};
case WEST -> new double[] {fz, 1 - fy};
case EAST -> new double[] {1 - fz, 1 - fy};
};
}
private static Direction faceFor(int axis, boolean negFace) { private static Direction faceFor(int axis, boolean negFace) {
return switch (axis) { return switch (axis) {
case 0 -> negFace ? Direction.WEST : Direction.EAST; case 0 -> negFace ? Direction.WEST : Direction.EAST;
+20
View File
@@ -51,6 +51,10 @@ public class PPTest extends JavaPlugin {
scenes.put("rotated", new Scene("rotated", this::rotated, 3, 4.5, -4, 3, 0.5, 2)); scenes.put("rotated", new Scene("rotated", this::rotated, 3, 4.5, -4, 3, 0.5, 2));
scenes.put("rotated2", new Scene("rotated2", this::rotated, -0.5, 3.5, 9.5, -0.5, 0.5, 5)); scenes.put("rotated2", new Scene("rotated2", this::rotated, -0.5, 3.5, 9.5, -0.5, 0.5, 5));
scenes.put("redstone", new Scene("redstone", this::redstone, 3, 3, -3, 3, 0, 2)); scenes.put("redstone", new Scene("redstone", this::redstone, 3, 3, -3, 3, 0, 2));
scenes.put("piston", new Scene("piston", this::piston, 4.5, 3.2, -3.5, 5, 1, 1));
scenes.put("pistonback", new Scene("pistonback", this::piston, 5.5, 3.2, 5.5, 5, 1, 1));
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)); scenes.put("vehicles", new Scene("vehicles", this::vehicles, 5, 3, -7, 5, 0.6, 1.5));
} }
@@ -442,6 +446,22 @@ public class PPTest extends JavaPlugin {
put.accept(new int[] {4, 0, 3}, "blue_stained_glass_pane[west=true,north=true]"); put.accept(new int[] {4, 0, 3}, "blue_stained_glass_pane[west=true,north=true]");
} }
/** x/y blockstate rotations without uvlock: pistons in all six directions, observers, dispensers, logs. */
private void piston(Location o) {
String[] f = {"down", "up", "north", "south", "east", "west"};
for (int i = 0; i < f.length; i++) {
o.clone().add(i * 2, 1, 0).getBlock().setBlockData(
Bukkit.createBlockData("minecraft:piston[facing=" + f[i] + ",extended=false]"), false);
o.clone().add(i * 2, 1, 2).getBlock().setBlockData(
Bukkit.createBlockData("minecraft:observer[facing=" + f[i] + "]"), false);
o.clone().add(i * 2, 0, 2).getBlock().setBlockData(
Bukkit.createBlockData("minecraft:dispenser[facing=" + f[i] + "]"), false);
}
o.clone().add(0, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:oak_log[axis=x]"), false);
o.clone().add(2, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:oak_log[axis=z]"), false);
o.clone().add(4, 0, -2).getBlock().setBlockData(Bukkit.createBlockData("minecraft:furnace[facing=north]"), false);
}
private void vehicles(Location o) { private void vehicles(Location o) {
spawn(o, 0, 0, 0, 0, EntityType.CHEST_MINECART); spawn(o, 0, 0, 0, 0, EntityType.CHEST_MINECART);
spawn(o, 1.8, 0, 0, 90, EntityType.FURNACE_MINECART); spawn(o, 1.8, 0, 0, 90, EntityType.FURNACE_MINECART);