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 5d486b2..2678342 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/ModelBaker.java @@ -58,17 +58,26 @@ public final class ModelBaker { double[] rotOrigin = null; int rotAxis = -1; double rotAngle = 0; + double[] rotMatrix = null; boolean rescale = false; - if (dto.rotation != null && dto.rotation.angle != 0 && dto.rotation.origin != null) { - rotOrigin = new double[] { - dto.rotation.origin[0] / 16.0, dto.rotation.origin[1] / 16.0, dto.rotation.origin[2] / 16.0 - }; - rotAxis = axisIndex(dto.rotation.axis); - rotAngle = Math.toRadians(dto.rotation.angle); - rescale = dto.rotation.rescale; + ModelFileDto.RotationDto rot = dto.rotation; + if (rot != null && rot.origin != null) { + if (rot.axis == null && (rot.x != null || rot.y != null || rot.z != null)) { + rotMatrix = eulerXyz(rot.x, rot.y, rot.z); + if (rotMatrix != null) rotAxis = Element.AXIS_MATRIX; + } else if (rot.angle != 0) { + rotAxis = axisIndex(rot.axis); + rotAngle = Math.toRadians(rot.angle); + } + if (rotAxis >= 0) { + rotOrigin = new double[] {rot.origin[0] / 16.0, rot.origin[1] / 16.0, rot.origin[2] / 16.0}; + rescale = rot.rescale; + } } - Element modelElement = new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale); + Element modelElement = rotMatrix != null + ? new Element(from, to, faces, rotOrigin, rotMatrix, rescale, null, 0, 0) + : new Element(from, to, faces, rotOrigin, rotAxis, rotAngle, rescale); // Apply blockstate variant rotation (90-degree steps) to box + faces + element rotation. for (int i = 0; i < xSteps; i++) { @@ -76,8 +85,8 @@ public final class ModelBaker { from = r[0]; to = r[1]; faces = rotateFacesX(faces); - if (rotAxis >= 0) { - rotOrigin = rotatePointX(rotOrigin); + if (rotAxis >= 0) rotOrigin = rotatePointX(rotOrigin); + if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) { AxisRotation na = rotateAxisX(rotAxis); rotAxis = na.axis(); if (na.flip()) rotAngle = -rotAngle; @@ -88,8 +97,8 @@ public final class ModelBaker { from = r[0]; to = r[1]; faces = rotateFacesY(faces); - if (rotAxis >= 0) { - rotOrigin = rotatePointY(rotOrigin); + if (rotAxis >= 0) rotOrigin = rotatePointY(rotOrigin); + if (rotAxis >= 0 && rotAxis < Element.AXIS_MATRIX) { AxisRotation na = rotateAxisY(rotAxis); rotAxis = na.axis(); if (na.flip()) rotAngle = -rotAngle; @@ -103,7 +112,20 @@ public final class ModelBaker { 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)); + baked.add( + rotMatrix != null + ? new Element( + from, + to, + faces, + rotOrigin, + conjugate(rotMatrix, xSteps, ySteps), + rescale, + unrotated, + xSteps, + ySteps) + : new Element( + from, to, faces, rotOrigin, rotAxis, rotAngle, rescale, unrotated, xSteps, ySteps)); // Accumulate average color from the element's face textures. for (Face f : faces) { @@ -153,6 +175,48 @@ public final class ModelBaker { }; } + /** + * Vanilla {@code EulerXYZRotation}: {@code Matrix4f.rotationZYX(z, y, x)} = Rz·Ry·Rx (degrees), row-major; + * null when it is the identity (vanilla then skips the rotation and its rescale). + */ + static double[] eulerXyz(Double xDeg, Double yDeg, Double zDeg) { + double x = Math.toRadians(xDeg == null ? 0 : xDeg); + double y = Math.toRadians(yDeg == null ? 0 : yDeg); + double z = Math.toRadians(zDeg == null ? 0 : zDeg); + if (x == 0 && y == 0 && z == 0) return null; + double cx = Math.cos(x), + sx = Math.sin(x), + cy = Math.cos(y), + sy = Math.sin(y), + cz = Math.cos(z), + sz = Math.sin(z); + double[] rz = {cz, -sz, 0, sz, cz, 0, 0, 0, 1}; + double[] ry = {cy, 0, sy, 0, 1, 0, -sy, 0, cy}; + double[] rx = {1, 0, 0, 0, cx, -sx, 0, sx, cx}; + return mul(mul(rz, ry), rx); + } + + /** The element rotation as seen after the blockstate rotation: V·M·Vᵀ (V = Element#rotate of the axes). */ + private static double[] conjugate(double[] m, int xSteps, int ySteps) { + double[] v = new double[9]; + for (int a = 0; a < 3; a++) { + double[] col = Element.rotate(a == 0 ? 1 : 0, a == 1 ? 1 : 0, a == 2 ? 1 : 0, xSteps, ySteps, false); + v[a] = col[0]; + v[3 + a] = col[1]; + v[6 + a] = col[2]; + } + double[] vt = {v[0], v[3], v[6], v[1], v[4], v[7], v[2], v[5], v[8]}; + return mul(mul(v, m), vt); + } + + private static double[] mul(double[] a, double[] b) { + double[] r = new double[9]; + for (int i = 0; i < 3; i++) + for (int j = 0; j < 3; j++) + r[3 * i + j] = a[3 * i] * b[j] + a[3 * i + 1] * b[3 + j] + a[3 * i + 2] * b[6 + j]; + return r; + } + /** 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]; @@ -160,6 +224,8 @@ public final class ModelBaker { Face f = e.faces[d.ordinal()]; if (f != null) locked[d.ordinal()] = f.withUvLock(uvLockTransform(d, xSteps, ySteps)); } + if (e.rotMatrix != null) + return new Element(e.from, e.to, locked, e.rotOrigin, e.rotMatrix, e.rescale, null, 0, 0); return new Element(e.from, e.to, locked, e.rotOrigin, e.rotAxis, e.rotAngleRad, e.rescale); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java index 38e89ab..e638a70 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/dto/ModelFileDto.java @@ -32,7 +32,10 @@ public class ModelFileDto { public static class RotationDto { public double[] origin; // 0..16 public String axis; // "x" | "y" | "z" - public double angle; // -45..45 in 22.5 steps + public double angle; // degrees (vanilla models: 22.5 steps) + // Since 26.x alternatively Euler angles in degrees instead of axis/angle (CuboidRotation.EulerXYZRotation, + // matrix Rz·Ry·Rx), e.g. the chains of template_hanging_sign_rot_3. + public Double x, y, z; public boolean rescale; } } 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 956daae..54c0d4e 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 @@ -15,14 +15,26 @@ public final class Element { // Element rotation (0..1 origin), null/zero when axis-aligned. public final double[] rotOrigin; // length 3, 0..1, may be null - public final int rotAxis; // 0=x,1=y,2=z, -1 when none - public final double rotAngleRad; // radians + public final int rotAxis; // 0=x,1=y,2=z, AXIS_MATRIX (Euler x/y/z rotation in rotMatrix), -1 when none + public final double rotAngleRad; // radians (single-axis rotations only) public final boolean rescale; + /** {@link #rotAxis} of an element rotated by a general (Euler x/y/z) rotation held in {@link #rotMatrix}. */ + public static final int AXIS_MATRIX = 3; + // cos/sin of the element rotation (+angle: local -> block) and of its inverse (-angle: block -> local), // precomputed for the intersector's hot path. public final double rotCos, rotSin, invRotCos, invRotSin; + // General rotation (rotAxis == AXIS_MATRIX): row-major 3x3 local -> block and its inverse (transpose); null + // otherwise. + public final double[] rotMatrix, invRotMatrix; + + // Vanilla rescale (CuboidRotation.computeRescale): the element is scaled per LOCAL axis by + // 1 / max|component of R·axis| before the rotation (1/cos 22.5° or 1/cos 45° on the axes the rotation tilts), + // so a rotated cross plane still spans the whole block. Reciprocals (block -> local); null without rescale. + public final double[] invScale; + // 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. @@ -42,6 +54,20 @@ public final class Element { this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, null, 0, 0); } + /** An element rotated by a general rotation matrix (row-major 3x3, local -> block, e.g. Euler x/y/z). */ + public Element( + double[] from, + double[] to, + Face[] faces, + double[] rotOrigin, + double[] rotMatrix, + boolean rescale, + Element unrotated, + int xSteps, + int ySteps) { + this(from, to, faces, rotOrigin, AXIS_MATRIX, 0, rescale, unrotated, xSteps, ySteps, rotMatrix); + } + public Element( double[] from, double[] to, @@ -53,6 +79,21 @@ public final class Element { Element unrotated, int xSteps, int ySteps) { + this(from, to, faces, rotOrigin, rotAxis, rotAngleRad, rescale, unrotated, xSteps, ySteps, null); + } + + private Element( + double[] from, + double[] to, + Face[] faces, + double[] rotOrigin, + int rotAxis, + double rotAngleRad, + boolean rescale, + Element unrotated, + int xSteps, + int ySteps, + double[] rotMatrix) { this.unrotated = unrotated; this.xSteps = xSteps; this.ySteps = ySteps; @@ -67,6 +108,41 @@ public final class Element { this.rotSin = Math.sin(rotAngleRad); this.invRotCos = Math.cos(-rotAngleRad); this.invRotSin = Math.sin(-rotAngleRad); + this.rotMatrix = rotAxis == AXIS_MATRIX ? rotMatrix : null; + this.invRotMatrix = this.rotMatrix == null ? null : transpose(this.rotMatrix); + this.invScale = rescale && !isAxisAligned() ? inverseRescale(linearRotation()) : null; + } + + /** The rotation as a row-major 3x3 (local -> block), identity when axis-aligned. */ + public double[] linearRotation() { + if (rotMatrix != null) return rotMatrix.clone(); + double c = rotCos, s = rotSin; + return switch (isAxisAligned() ? -1 : rotAxis) { + case 0 -> new double[] {1, 0, 0, 0, c, -s, 0, s, c}; + case 1 -> new double[] {c, 0, s, 0, 1, 0, -s, 0, c}; + case 2 -> new double[] {c, -s, 0, s, c, 0, 0, 0, 1}; + default -> new double[] {1, 0, 0, 0, 1, 0, 0, 0, 1}; + }; + } + + /** Vanilla rescale factors per local axis: 1 / max|component of R·axis| (column of R); as reciprocals. */ + private static double[] inverseRescale(double[] r) { + double[] inv = new double[3]; + for (int a = 0; a < 3; a++) { + double max = Math.max(Math.max(Math.abs(r[a]), Math.abs(r[3 + a])), Math.abs(r[6 + a])); + inv[a] = max; // 1 / (1 / max) + } + return inv; + } + + /** The rescale factors per local axis (local -> block), {1,1,1} without rescale. */ + public double[] rescaleFactors() { + if (invScale == null) return new double[] {1, 1, 1}; + return new double[] {1 / invScale[0], 1 / invScale[1], 1 / invScale[2]}; + } + + private static double[] transpose(double[] m) { + return new double[] {m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]}; } /** Model space -> world: vanilla blockstate rotation (x steps, then y steps; clockwise about the centre). */ @@ -93,6 +169,6 @@ public final class Element { } public boolean isAxisAligned() { - return rotAxis < 0 || rotAngleRad == 0.0; + return rotAxis < 0 || (rotAxis != AXIS_MATRIX && rotAngleRad == 0.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 7cf7056..99883c5 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 @@ -460,11 +460,13 @@ public final class ItemGeometry { switch (e.rotAxis) { case 0 -> Affine.rotX(e.rotAngleRad); case 1 -> Affine.rotY(e.rotAngleRad); - default -> Affine.rotZ(e.rotAngleRad); + case 2 -> Affine.rotZ(e.rotAngleRad); + default -> new Affine(e.linearRotation(), new double[] {0, 0, 0}); }; - if (e.rescale) { - double f = 1 / Math.cos(e.rotAngleRad); - r = r.mul(Affine.scale(e.rotAxis == 0 ? 1 : f, e.rotAxis == 1 ? 1 : f, e.rotAxis == 2 ? 1 : f)); + if (e.invScale != null) { + // Vanilla CuboidRotation rescale: R·S, S = 1 / max|component of R·axis| per local axis. + double[] f = e.rescaleFactors(); + r = r.mul(Affine.scale(f[0], f[1], f[2])); } return Affine.translation(o[0], o[1], o[2]).mul(r).mul(Affine.translation(-o[0], -o[1], -o[2])); } 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 8332928..715a47a 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 @@ -20,6 +20,7 @@ import org.bukkit.util.Vector; public final class ElementIntersector { private static final double EPS = 1e-7; + private static final Direction[] DIRECTIONS = Direction.values(); private ElementIntersector() {} @@ -154,14 +155,41 @@ public final class ElementIntersector { return null; } - /** Rotated element: transform the ray into the element's local (unrotated) frame, then slab-test. */ + /** + * Rotated element: transform the ray into the element's local (unrotated, unscaled) frame, then slab-test. + * Vanilla bakes {@code origin + R·S·(p - origin)} (S = rescale, see {@link Element#invScale}); the inverse is + * affine, so the ray parameter t stays the block-space one. + */ private static boolean intersectObb( Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) { double[] v = sc.v; - rotate(v, ox - e.rotOrigin[0], oy - e.rotOrigin[1], oz - e.rotOrigin[2], e.rotAxis, e.invRotCos, e.invRotSin); - double lox = v[0] + e.rotOrigin[0], loy = v[1] + e.rotOrigin[1], loz = v[2] + e.rotOrigin[2]; - rotate(v, dx, dy, dz, e.rotAxis, e.invRotCos, e.invRotSin); - return slab(sc, lox, loy, loz, v[0], v[1], v[2], e.from, e.to, e); + double[] o = e.rotOrigin; + unrotateElement(v, e, ox - o[0], oy - o[1], oz - o[2]); + double lox = v[0], loy = v[1], loz = v[2]; + unrotateElement(v, e, dx, dy, dz); + double ldx = v[0], ldy = v[1], ldz = v[2]; + double[] is = e.invScale; + if (is != null) { + lox *= is[0]; + loy *= is[1]; + loz *= is[2]; + ldx *= is[0]; + ldy *= is[1]; + ldz *= is[2]; + } + return slab(sc, lox + o[0], loy + o[1], loz + o[2], ldx, ldy, ldz, e.from, e.to, e); + } + + /** Inverse element rotation (block -> local) of a vector into out, without the rescale. */ + private static void unrotateElement(double[] out, Element e, double x, double y, double z) { + double[] m = e.invRotMatrix; + if (m != null) { + out[0] = m[0] * x + m[1] * y + m[2] * z; + out[1] = m[3] * x + m[4] * y + m[5] * z; + out[2] = m[6] * x + m[7] * y + m[8] * z; + } else { + rotate(out, x, y, z, e.rotAxis, e.invRotCos, e.invRotSin); + } } /** Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. */ @@ -176,6 +204,9 @@ public final class ElementIntersector { double[] from, double[] to, Element obb) { + if (from[0] > to[0] || from[1] > to[1] || from[2] > to[2]) { + return invertedBox(sc, ox, oy, oz, dx, dy, dz, from, to, obb); + } double tmin = Double.NEGATIVE_INFINITY; double tmax = Double.POSITIVE_INFINITY; int axis = -1; @@ -228,8 +259,16 @@ public final class ElementIntersector { Direction dir = faceFor(axis, negFace); if (obb != null) { // rotate the normal back into block space + // (rescale is diagonal in the local frame, so an axis normal keeps its direction: n = R·n_local) double[] v = sc.v; - rotate(v, dir.nx, dir.ny, dir.nz, obb.rotAxis, obb.rotCos, obb.rotSin); + double[] m = obb.rotMatrix; + if (m != null) { + v[0] = m[0] * dir.nx + m[1] * dir.ny + m[2] * dir.nz; + v[1] = m[3] * dir.nx + m[4] * dir.ny + m[5] * dir.nz; + v[2] = m[6] * dir.nx + m[7] * dir.ny + m[8] * dir.nz; + } else { + rotate(v, dir.nx, dir.ny, dir.nz, obb.rotAxis, obb.rotCos, obb.rotSin); + } sc.hitNx = v[0]; sc.hitNy = v[1]; sc.hitNz = v[2]; @@ -250,6 +289,88 @@ public final class ElementIntersector { return true; } + /** + * Element with {@code from > to} on some axis (spawner/vault/trial-spawner inner faces, snow layer 2's inner + * bottom): vanilla places the WEST/DOWN/NORTH face at {@code from} and EAST/UP/SOUTH at {@code to} of its axis, + * and every inverted OTHER axis mirrors the quad, flipping its winding. So these faces point into the box and + * are seen from inside (through the cage: the far wall). Each face is tested as its own back-face-culled quad; + * the nearest visible one wins. Texture coordinates follow the mirrored corners (signed span). + */ + private static boolean invertedBox( + Scratch sc, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double[] from, + double[] to, + Element obb) { + double[] o = sc.o, d = sc.d; + o[0] = ox; + o[1] = oy; + o[2] = oz; + d[0] = dx; + d[1] = dy; + d[2] = dz; + 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]; + 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; + double nx = a == 0 ? bestSign : 0, ny = a == 1 ? bestSign : 0, nz = a == 2 ? bestSign : 0; + if (obb != null) { + double[] v = sc.v; + double[] m = obb.rotMatrix; + if (m != null) { + v[0] = m[0] * nx + m[1] * ny + m[2] * nz; + v[1] = m[3] * nx + m[4] * ny + m[5] * nz; + v[2] = m[6] * nx + m[7] * ny + m[8] * nz; + } else { + rotate(v, nx, ny, nz, obb.rotAxis, obb.rotCos, obb.rotSin); + } + nx = v[0]; + ny = v[1]; + nz = v[2]; + } + double fx = signedFrac(o[0] + d[0] * best, from[0], to[0]); + double fy = signedFrac(o[1] + d[1] * best, from[1], to[1]); + double fz = signedFrac(o[2] + d[2] * best, from[2], to[2]); + sc.hitT = best; + sc.hitDir = bestDir; + sc.hitNx = nx; + sc.hitNy = ny; + sc.hitNz = nz; + sc.hitS = faceS(bestDir, fx, fy, fz); + sc.hitT2 = faceT(bestDir, fy, fz); + return true; + } + + /** 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-6) return 0; + double f = (v - from) / span; + return f < 0 ? 0 : Math.min(f, 1); + } + /** * Face-local horizontal coordinate s 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 (s, t), so north/east diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java index be15694..8968957 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java @@ -11,6 +11,7 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.MathUtil; +import java.util.EnumSet; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Material; @@ -45,6 +46,43 @@ public final class SnapshotRaytracer { // layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind. private static final double ADDITIVE = -1; + // Brightness of a surface under water seen from above, by sky light level (index): vanilla lightmap + // (lightmap.fsh: level / (4 - 3·level), mixed halfway with notGamma = default brightness option 0.5, overworld + // ambient 0). Sky light loses one level per water block above the face (water's light block = 1), so the floor + // gets darker with VERTICAL depth; the path length through the water plays no role in vanilla when the camera + // is above the surface (no water fog there): one translucent surface layer over the dimmed scene. + private static final double[] SKY_LIGHT = new double[16]; + + static { + for (int level = 0; level < 16; level++) { + double l = level / 15.0; + double b = l / (4 - 3 * l); + double inv = 1 - b; + SKY_LIGHT[level] = (b + (1 - inv * inv * inv * inv)) / 2; + } + } + + // Vanilla HalfTransparentBlock / LeavesBlock that do not occlude: water renders its faces against them with the + // water overlay texture (FluidRenderer). Blue ice is a HalfTransparentBlock too but occludes, packed ice is not. + private static final EnumSet WATER_OVERLAY_NEIGHBOURS = buildWaterOverlayNeighbours(); + + private static EnumSet buildWaterOverlayNeighbours() { + EnumSet set = EnumSet.noneOf(Material.class); + for (Material m : Material.values()) { + String n = m.name(); + if (m.isLegacy()) continue; + if (n.equals("GLASS") + || n.equals("TINTED_GLASS") + || n.endsWith("_STAINED_GLASS") + || n.equals("ICE") + || n.equals("FROSTED_ICE") + || n.equals("SLIME_BLOCK") + || n.equals("HONEY_BLOCK") + || n.endsWith("_LEAVES")) set.add(m); + } + return set; + } + private final BlockModelRegistry registry; private final BiomeTintProvider tintProvider; private final SkyRenderer skyRenderer; @@ -146,7 +184,10 @@ public final class SnapshotRaytracer { } // Compose back to front: final surface (fogged), the liquid volume where it lies, translucent layers. - int color = Double.isInfinite(finalT) ? finalColor : fog(finalColor, finalT, skyColor); + // Surfaces under water (seen from above it) are dimmed by their depth like vanilla sky light. + int color = Double.isInfinite(finalT) + ? finalColor + : fog(blocks.underwaterLight(finalColor, finalT), finalT, skyColor); boolean liquidPending = blocks.liquidT >= 0 && blocks.liquidT < finalT; for (int i = layers - 1; i >= 0; i--) { if (liquidPending && layerT[i] < blocks.liquidT) { @@ -157,7 +198,8 @@ public final class SnapshotRaytracer { // Added light fades with distance like the surface it would otherwise be. color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - fogFactor(layerT[i]))); } else { - color = ColorUtil.mix(fog(layerColor[i], layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]); + int layer = blocks.underwaterLight(layerColor[i], layerT[i]); + color = ColorUtil.mix(fog(layer, layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]); } } if (liquidPending) color = blocks.applyLiquid(color, finalT, skyColor); @@ -191,6 +233,9 @@ public final class SnapshotRaytracer { private boolean done; double liquidT = -1; + // The ray starts inside a liquid (underwater photo): the liquid acts as a fog along the path instead. + private final boolean startsInLiquid; + private Material left; // material of the voxel the ray passed through last private int liquidColor; private double liquidFactor; private boolean reflected; @@ -211,6 +256,8 @@ public final class SnapshotRaytracer { this.dy = direction.getY(); this.dz = direction.getZ(); this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz); + BlockData start = snapshot.getBlockData((int) Math.floor(ox), (int) Math.floor(oy), (int) Math.floor(oz)); + this.startsInLiquid = start.getMaterial() != Material.AIR && registry.get(start).transparency > 0; } /** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */ @@ -222,20 +269,31 @@ public final class SnapshotRaytracer { } int bx = dda.x, by = dda.y, bz = dda.z; BlockData data = snapshot.getBlockData(bx, by, bz); - if (data.getMaterial() == Material.AIR) { - advance(); + Material material = data.getMaterial(); + if (material == Material.AIR) { + advance(material); continue; } ResolvedModel model = registry.get(data); FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT); if (hit == null) { - advance(); + advance(material); continue; } if (model.transparency > 0) { + if (liquidT >= 0 && material == Material.WATER && WATER_OVERLAY_NEIGHBOURS.contains(left)) { + // Under water, leaving glass/ice/slime/honey/leaves into water again: vanilla FluidRenderer + // draws the water's faces against such non-occluding blocks (side faces with the water + // overlay texture), facing into the block, so from inside it the far wall is a water layer. + int c = shadeAndTint(hit, data, snapshot, bx, by, bz); + int a = (int) Math.round((1 - model.transparency) * 255); + advance(material); + return new BlockHit( + ColorUtil.argb(a, ColorUtil.red(c), ColorUtil.green(c), ColorUtil.blue(c)), hit.t()); + } recordLiquid(model, hit, data, bx, by, bz); - advance(); + advance(material); continue; } int color = shadeAndTint(hit, data, snapshot, bx, by, bz); @@ -248,7 +306,9 @@ public final class SnapshotRaytracer { return null; } - private void advance() { + /** Steps to the next voxel; {@code material} is the one of the voxel being left. */ + private void advance(Material material) { + left = material; dda.advance(); steps++; } @@ -269,15 +329,50 @@ public final class SnapshotRaytracer { } } - /** Liquid volume over {@code behind}: denser with depth down to the final surface, plus reflection. */ + /** + * The liquid over {@code behind} (everything beyond its surface, already dimmed by + * {@link #underwaterLight}): seen from above, one translucent surface layer like vanilla, independent of + * the path length through the liquid (before, the surface weight grew with the path, so blocks a few + * blocks deep and anything seen at a shallow angle vanished in the water colour). From inside the liquid + * the path-length model stays (a fog). Plus reflection. + */ int applyLiquid(int behind, double finalT, int skyColor) { - double depthIn = Double.isInfinite(finalT) ? 0 : finalT - liquidT; int surface = fog(liquidColor, liquidT, skyColor); - int c = ColorUtil.mix(behind, surface, liquidFactor, (1 - liquidFactor) * (1 + depthIn / 5.0)); + double surfaceWeight = 1 - liquidFactor; + if (startsInLiquid) { + double depthIn = Double.isInfinite(finalT) ? 0 : finalT - liquidT; + surfaceWeight *= 1 + depthIn / 5.0; + } + int c = ColorUtil.mix(behind, surface, liquidFactor, surfaceWeight); if (reflected) c = ColorUtil.mix(c, reflectionColor, 1 - reflectionFactor, reflectionFactor); return c; } + /** + * {@code color} of a surface at ray parameter {@code t} dimmed like vanilla sky light under water, when it + * lies beyond the water surface and the ray started above it: level 15 minus the blocks from the block in + * front of the surface up to the topmost water block of that column (water, and non-occluding blocks below + * it such as glass, cost one level each). Unchanged without water on the ray. + */ + int underwaterLight(int color, double t) { + if (liquidT < 0 || startsInLiquid || !(t > liquidT) || Double.isInfinite(t)) return color; + // A point just in front of the surface (back along the ray), i.e. in the block that lights it. + double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz); + int bx = (int) Math.floor(ox + dx * (t - back)); + int by = (int) Math.floor(oy + dy * (t - back)); + int bz = (int) Math.floor(oz + dz * (t - back)); + int water = 0; + for (int i = 0; i < 15; i++) { + Material m = snapshot.getBlockData(bx, by + i, bz).getMaterial(); + if (m == Material.WATER) { + water = i + 1; + } else if (m == Material.AIR || m == Material.CAVE_AIR || m == Material.VOID_AIR || m.isOccluding()) { + break; + } + } + return water == 0 ? color : ColorUtil.shade(color, SKY_LIGHT[15 - water]); + } + /** * Vanilla skips the faces between two translucent blocks of the same kind (stained glass walls, ice, * connected panes): true when the hit lies on the block boundary and the neighbour across it is the diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 5082df4..dd1c1f9 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -176,6 +176,19 @@ public class PPTest extends JavaPlugin { scenes.put("rf_vlayers_close", new Scene("rf_vlayers_close", this::rfVisualLayers, 3.2, 1.5, -2.4, 3.2, 0.5, 1)); scenes.put("rf_vstates", new Scene("rf_vstates", this::rfVisualStates, 5.5, 3.4, -6, 5.5, 0.9, 2.5)); scenes.put("rf_vstates_close", new Scene("rf_vstates_close", this::rfVisualStates, 3.2, 1.7, -2.6, 3.2, 0.9, 0.5)); + // Block geometry (vanilla FaceBakery): rescaled element rotations (cross plants, amethyst, campfire, fire, + // raised rails, tripwire hooks, coral wall fans, dripleaf, stems, sea pickles), Euler x/y/z element + // rotations (hanging sign rot 3/7/11/15 chains), inverted elements (spawner/vault inner faces), 22.5° steps. + scenes.put("geo_plants", new Scene("geo_plants", this::geoPlants, 5.5, 2.6, -3.2, 5.5, 0.4, 2.2)); + scenes.put("geo_plants_close", new Scene("geo_plants_close", this::geoPlants, 2.5, 1.1, -0.9, 2.5, 0.5, 1)); + scenes.put("geo_misc", new Scene("geo_misc", this::geoMisc, 6, 3.4, -4.5, 6, 0.5, 3)); + scenes.put("geo_misc_close", new Scene("geo_misc_close", this::geoMisc, 3, 1.6, -1.6, 3, 0.4, 1)); + scenes.put("geo_misc_signs", new Scene("geo_misc_signs", this::geoMisc, 4, 2.4, 3.5, 4, 2.2, 7)); + // Water depth vs. translucency: a pool with columns 1-6 blocks deep (x pairs), each with a glass block, + // a gold block and white stained glass on the sand bottom; seen from the shore, from above and from inside. + scenes.put("geo_water", new Scene("geo_water", this::geoWater, 6, 3.5, -5, 6, -3, 2.5)); + scenes.put("geo_water_top", new Scene("geo_water_top", this::geoWater, 6, 9, 2.4, 6, -4, 2.5)); + scenes.put("geo_water_under", new Scene("geo_water_under", this::geoWater, 11.5, -3.3, 0.3, 4, -5, 3)); } private void rfVisualLayers(Location o) { @@ -587,7 +600,7 @@ public class PPTest extends JavaPlugin { Location eye = o.clone().add(sc.camX, sc.camY, sc.camZ); Vector look = o.clone().add(sc.lookX, sc.lookY, sc.lookZ).toVector().subtract(eye.toVector()); eye.setDirection(look); - RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") ? new Resolution(960, 640) : new Resolution(480, 320), null); + RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") || sc.name.startsWith("geo_") ? new Resolution(960, 640) : new Resolution(480, 320), null); if (sc.name.equals("dbg")) { try { java.lang.reflect.Field f = DefaultScreenRenderer.class.getDeclaredField("objectBaker"); @@ -1588,6 +1601,102 @@ public class PPTest extends JavaPlugin { frame(o, 5, 5, 5, BlockFace.NORTH, false, synthMap(), 0, true); } + /** Rows of cross/rescaled plants on grass (z=0..3), potted plants and stems; see the geo_* scenes. */ + private void geoPlants(Location o) { + for (int x = -1; x <= 12; x++) for (int z = -1; z <= 5; z++) put(o, x, -1, z, "grass_block"); + String[] row0 = {"short_grass", "fern", "dandelion", "poppy", "azure_bluet", "oxeye_daisy", "cornflower", + "oak_sapling", "birch_sapling", "cherry_sapling", "dead_bush", "brown_mushroom"}; + String[] row1 = {"sweet_berry_bush[age=3]", "sugar_cane", "cobweb", "red_tulip", "wither_rose", "bush", + "short_dry_grass", "firefly_bush", "mangrove_propagule[hanging=false,stage=1]", "lily_of_the_valley", + "pumpkin_stem[age=7]", "crimson_roots"}; + String[] row3 = {"potted_fern", "potted_oak_sapling", "potted_red_tulip", "potted_dead_bush", + "potted_azalea_bush", "potted_mangrove_propagule", "big_dripleaf_stem[facing=north]", + "sea_pickle[pickles=4,waterlogged=false]", "sea_pickle[pickles=1,waterlogged=false]", "azalea", + "flowering_azalea", "pointed_dripstone[thickness=tip,vertical_direction=up]"}; + for (int x = 0; x < 12; x++) { + put(o, x, 0, 0, row0[x]); + put(o, x, 0, 1, row1[x]); + put(o, x, 0, 3, row3[x]); + } + for (int x = 0; x < 12; x += 3) { + put(o, x, 0, 2, "tall_grass[half=lower]"); + put(o, x, 1, 2, "tall_grass[half=upper]"); + put(o, x + 1, 0, 2, "sunflower[half=lower]"); + put(o, x + 1, 1, 2, "sunflower[half=upper]"); + } + put(o, 2, 0, 2, "big_dripleaf[facing=north,tilt=none]"); + put(o, 5, 0, 2, "melon_stem[age=4]"); + put(o, 8, 0, 2, "attached_pumpkin_stem[facing=east]"); + put(o, 9, 0, 2, "pumpkin"); + put(o, 11, 0, 2, "large_fern[half=lower]"); + put(o, 11, 1, 2, "large_fern[half=upper]"); + } + + /** + * Row z=0: amethyst cluster/buds (up/north/east), campfires, fire; z=2: raised rails, tripwire hooks, coral wall + * fans, spawner/vault/trial spawner (inverted inner faces), snow layer 2; z=6/7: hanging signs rot 0-15 under a + * stone ceiling (rot 3/7/11/15 = Euler chain rotation). + */ + private void geoMisc(Location o) { + put(o, 0, 0, 0, "amethyst_cluster[facing=up]"); + put(o, 1, 0, 0, "large_amethyst_bud[facing=up]"); + put(o, 2, 0, 0, "medium_amethyst_bud[facing=up]"); + put(o, 3, 0, 0, "small_amethyst_bud[facing=up]"); + put(o, 4, 0, 1, "amethyst_block"); + put(o, 4, 0, 0, "amethyst_cluster[facing=north]"); + put(o, 5, 0, 1, "amethyst_block"); + put(o, 5, 1, 1, "large_amethyst_bud[facing=up]"); + put(o, 6, 0, 0, "campfire[lit=true,facing=south]"); + put(o, 7, 0, 0, "campfire[lit=false,facing=east]"); + put(o, 8, 0, 0, "soul_campfire[lit=true,facing=south]"); + put(o, 9, 0, 0, "netherrack"); + put(o, 9, 1, 0, "fire"); + put(o, 10, 0, 1, "stone"); + put(o, 10, 0, 0, "fire[north=true]"); + put(o, 11, 0, 0, "soul_fire"); + put(o, 11, -1, 0, "soul_sand"); + put(o, 0, 0, 2, "rail[shape=ascending_east]"); + put(o, 1, 0, 2, "stone"); + put(o, 2, 0, 2, "powered_rail[shape=ascending_north,powered=true]"); + put(o, 2, 0, 1, "stone"); + put(o, 3, 0, 2, "detector_rail[shape=ascending_west]"); + put(o, 2, 0, 3, "stone"); + put(o, 4, 0, 3, "stone"); + put(o, 4, 0, 2, "tripwire_hook[attached=true,facing=north,powered=false]"); + put(o, 5, 0, 3, "stone"); + put(o, 5, 0, 2, "tripwire_hook[attached=true,facing=north,powered=true]"); + put(o, 6, 0, 3, "stone"); + put(o, 6, 0, 2, "tube_coral_wall_fan[facing=north,waterlogged=false]"); + put(o, 7, 0, 3, "stone"); + put(o, 7, 0, 2, "fire_coral_wall_fan[facing=north,waterlogged=false]"); + put(o, 8, 0, 2, "spawner"); + put(o, 9, 0, 2, "vault[facing=north]"); + put(o, 10, 0, 2, "trial_spawner"); + put(o, 11, 0, 2, "snow[layers=2]"); + for (int x = -1; x <= 9; x++) for (int z = 5; z <= 8; z++) put(o, x, 4, z, "stone"); + for (int r = 0; r < 16; r++) put(o, r % 8, 3, 6 + r / 8, "oak_hanging_sign[rotation=" + r + ",attached=false]"); + } + + /** Pool: water columns 1..6 deep (two blocks wide each) on sand, with glass/gold/white stained glass at the bottom. */ + private void geoWater(Location o) { + for (int x = -1; x <= 12; x++) + for (int z = -1; z <= 5; z++) + for (int y = -8; y <= -1; y++) { + boolean wall = x == -1 || x == 12 || z == -1 || z == 5; + int depth = x < 0 || x > 11 ? 0 : x / 2 + 1; + String b = wall ? "smooth_stone" : y >= -depth ? "water" : y == -depth - 1 ? "sand" : "stone"; + put(o, x, y, z, b); + } + for (int x = 0; x < 12; x += 2) { + int y = -(x / 2 + 1); + put(o, x, y, 1, "glass"); + put(o, x + 1, y, 1, "glass"); + put(o, x, y, 2, "gold_block"); + put(o, x + 1, y, 3, "white_stained_glass"); + } + for (int x = -1; x <= 12; x++) put(o, x, 0, 6, "stone"); + } + /** Black wall z=6 with a white vertical stripe (x=5) and a white horizontal stripe (y=5). */ private void camStripes(Location o) { for (int a = -6; a < 18; a++)