simplify raytracer, fluid, sky and render code: air fog moved into the camera medium, sky colour computed only when read, single ao neighbour lookups, culling test before tint and ao, allocation-free fluid heights and flow angle, one fluid model lookup per cell, shared normal rotation and face fractions in the element intersector, unified side-face table, dead overloads/interface/enums and convenience constructors removed, comments trimmed

This commit is contained in:
MineTec committed 2026-11-04 22:53:25 +01:00
1 parent 4348b5fca0
commit bc07631fec
14 files changed
+398 -685

No files matched your search

@@ -8,35 +8,39 @@ import org.bukkit.block.Biome;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* The fluid the camera is in and its fog, like vanilla 26.3 {@code FogRenderer} with {@code WaterFogEnvironment} / * What the camera looks through and its distance fog. In water/lava like vanilla 26.3 {@code FogRenderer} with
* {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd} (spherical distance from the * {@code WaterFogEnvironment} / {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd}
* camera) towards {@code fogColor}, applied to every surface; the sky is the fogged sky disc. * (spherical distance) towards {@code fogColor}; the sky is the fogged sky disc. In air: PixelPics' own
* atmospheric fog towards the sky colour (not vanilla).
* *
* <p>Water: start/end/colour are the biome attributes {@code water_fog_start_distance} (-8), * <p>Water: start/end/colour are the biome attributes {@code water_fog_start_distance} (-8),
* {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), each blended * {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), blended over
* over the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1). * the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1).
* The player's water vision is taken as fully adapted (1: after 30 s under water; {@code LocalPlayer * Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and
* .getWaterVision}), which keeps the end distance and brightens the colour to a maximum channel of 1. Lava: * brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance).
* #991A00 from 0.25 to 1 block (no fire resistance).
*/ */
public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd) { public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd) {
public static final CameraMedium AIR = new CameraMedium(FluidRenderer.NONE, 0, 0, 0); public static final CameraMedium AIR = new CameraMedium(FluidRenderer.NONE, 0, 0, 0);
private static final double WATER_VISION = 1.0;
private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0}; private static final double[] KERNEL = {0, 1, 4, 6, 4, 1, 0};
// Atmospheric fog in air.
private static final double AIR_FOG_START = 128;
private static final double AIR_FOG_END = 256;
private static final double AIR_FOG_MAX = 0.75;
// Vanilla sky disc (SkyRenderer.buildSkyDisc): a fan at height 16 around the camera, rim octagon at 512. // Vanilla sky disc (SkyRenderer.buildSkyDisc): a fan at height 16 around the camera, rim octagon at 512.
private static final double DISC_HEIGHT = 16; private static final double DISC_HEIGHT = 16;
private static final double DISC_RADIUS = 512; private static final double DISC_RADIUS = 512;
private static final double DISC_APOTHEM = DISC_RADIUS * Math.cos(Math.PI / 8); private static final double DISC_APOTHEM = DISC_RADIUS * Math.cos(Math.PI / 8);
private static final double DISC_RIM_DISTANCE = Math.hypot(DISC_RADIUS, DISC_HEIGHT);
public boolean inFluid() { public boolean inFluid() {
return fluid != FluidRenderer.NONE; return fluid != FluidRenderer.NONE;
} }
/** The medium at {@code eye} in {@code snapshot}. */ static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
public static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
double x = eye.getX(), y = eye.getY(), z = eye.getZ(); double x = eye.getX(), y = eye.getY(), z = eye.getZ();
int fluid = FluidRenderer.cameraFluid(snapshot::getBlockData, x, y, z); int fluid = FluidRenderer.cameraFluid(snapshot::getBlockData, x, y, z);
if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0); if (fluid == FluidRenderer.LAVA) return new CameraMedium(fluid, 0x991A00, 0.25, 1.0);
@@ -60,9 +64,9 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
Biome biome = snapshot.getBiome((ix - 2 + i) * 4, by, (iz - 2 + k) * 4); Biome biome = snapshot.getBiome((ix - 2 + i) * 4, by, (iz - 2 + k) * 4);
BiomeClimate.Climate c = biome == null ? BiomeClimate.DEFAULT : tints.climate(biome); BiomeClimate.Climate c = biome == null ? BiomeClimate.DEFAULT : tints.climate(biome);
int fog = c.waterFogColor(); int fog = c.waterFogColor();
r += w * ((fog >> 16) & 0xFF) / 255.0; r += w * ColorUtil.red(fog) / 255.0;
g += w * ((fog >> 8) & 0xFF) / 255.0; g += w * ColorUtil.green(fog) / 255.0;
b += w * (fog & 0xFF) / 255.0; b += w * ColorUtil.blue(fog) / 255.0;
start += w * c.waterFogStart(); start += w * c.waterFogStart();
end += w * c.waterFogEnd(); end += w * c.waterFogEnd();
total += w; total += w;
@@ -74,14 +78,13 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
b /= total; b /= total;
start /= total; start /= total;
end /= total; end /= total;
// FogRenderer.computeFogColor: brighten by water vision towards a maximum channel of 1. // FogRenderer.computeFogColor with water vision 1: brightened to a maximum channel of 1.
if (r != 0 && g != 0 && b != 0) { if (r != 0 && g != 0 && b != 0) {
double scale = 1 / Math.max(r, Math.max(g, b)); double scale = 1 / Math.max(r, Math.max(g, b));
r = r + (r * scale - r) * WATER_VISION; r = r + (r * scale - r);
g = g + (g * scale - g) * WATER_VISION; g = g + (g * scale - g);
b = b + (b * scale - b) * WATER_VISION; b = b + (b * scale - b);
} }
end *= Math.max(0.25, WATER_VISION);
int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF; int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF;
return new CameraMedium(fluid, color, start, end); return new CameraMedium(fluid, color, start, end);
} }
@@ -94,26 +97,36 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
return Math.clamp((int) Math.round(v * 255), 0, 255); return Math.clamp((int) Math.round(v * 255), 0, 255);
} }
/** fog.glsl linear_fog_value at distance {@code d}. */ /** Fog fraction at distance {@code d} (in fluids fog.glsl {@code linear_fog_value}). */
public double fogValue(double d) { double fogValue(double d) {
if (!inFluid()) {
if (d <= AIR_FOG_START) return 0;
return Math.clamp((d - AIR_FOG_START) / (AIR_FOG_END - AIR_FOG_START), 0, AIR_FOG_MAX);
}
if (d <= fogStart) return 0; if (d <= fogStart) return 0;
if (d >= fogEnd) return 1; if (d >= fogEnd) return 1;
return (d - fogStart) / (fogEnd - fogStart); return (d - fogStart) / (fogEnd - fogStart);
} }
/** {@code color} (RGB) fogged at distance {@code d}. */ /** {@code color} fogged at distance {@code d}; air fogs towards {@code skyColor}. */
public int fog(int color, double d) { int fog(int color, double d, int skyColor) {
double f = fogValue(d); double f = fogValue(d);
return f > 0 ? ColorUtil.mix(color, fogColor, 1 - f, f) : color & 0xFFFFFF; return f > 0 ? ColorUtil.mix(color, inFluid() ? fogColor : skyColor, 1 - f, f) : color;
}
/** Whether {@link #fog}/{@link #sky} of a surface at {@code t} (infinite = sky) read the sky colour. */
boolean needsSkyColor(double t) {
return Double.isInfinite(t) || (!inFluid() && t > AIR_FOG_START);
} }
/** /**
* The sky seen in direction {@code (dx, dy, dz)} (unit) from inside the fluid: vanilla draws the sky disc with * The sky seen in direction {@code (dx, dy, dz)} (unit). From inside a fluid vanilla draws the sky disc with fog
* fog over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles * over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles (centre 16,
* (centre 16, rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and * rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and beyond the disc
* beyond the disc only the clear colour (= fog colour) remains. * only the clear colour (= fog colour) remains.
*/ */
public int sky(int skyColor, double dx, double dy, double dz) { int sky(int skyColor, double dx, double dy, double dz) {
if (!inFluid()) return skyColor;
if (fluid == FluidRenderer.LAVA || dy <= 1e-9) return fogColor; if (fluid == FluidRenderer.LAVA || dy <= 1e-9) return fogColor;
double horizontal = Math.sqrt(dx * dx + dz * dz); double horizontal = Math.sqrt(dx * dx + dz * dz);
double radius = DISC_HEIGHT * horizontal / dy; double radius = DISC_HEIGHT * horizontal / dy;
@@ -125,7 +138,7 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
lambda = radius * Math.cos(rel - sector / 2) / DISC_APOTHEM; lambda = radius * Math.cos(rel - sector / 2) / DISC_APOTHEM;
} }
if (lambda >= 1) return fogColor; if (lambda >= 1) return fogColor;
double spherical = DISC_HEIGHT + (Math.hypot(DISC_RADIUS, DISC_HEIGHT) - DISC_HEIGHT) * lambda; double spherical = DISC_HEIGHT + (DISC_RIM_DISTANCE - DISC_HEIGHT) * lambda;
double cylindrical = DISC_HEIGHT + (DISC_RADIUS - DISC_HEIGHT) * lambda; double cylindrical = DISC_HEIGHT + (DISC_RADIUS - DISC_HEIGHT) * lambda;
double f = cylindrical > fogEnd ? 1 : Math.clamp(spherical / fogEnd, 0, 1); double f = cylindrical > fogEnd ? 1 : Math.clamp(spherical / fogEnd, 0, 1);
return ColorUtil.mix(skyColor, fogColor, 1 - f, f); return ColorUtil.mix(skyColor, fogColor, 1 - f, f);
@@ -11,11 +11,9 @@ import java.util.List;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns * Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns the nearest drawn
* the nearest drawn face hit. Axis-aligned boxes use a slab test; rotated elements are handled as * face hit. Rotated elements are slab-tested in their local frame. Cut-out texels are holes the ray passes through;
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels * partially transparent texels are returned with their alpha for the caller to blend.
* (alpha &le; threshold) are treated as holes so the ray passes through (cutout for plants, glass);
* partially transparent texels are returned with their alpha, the caller blends them (stained glass, ice).
*/ */
public final class ElementIntersector { public final class ElementIntersector {
@@ -24,10 +22,7 @@ public final class ElementIntersector {
private ElementIntersector() {} private ElementIntersector() {}
/** /** Per-thread scratch of one {@link #intersect} call: candidate faces as parallel arrays, so nothing is allocated. */
* Per-thread scratch of one {@link #intersect} call: the candidate faces as parallel primitive arrays, so
* the hot path allocates nothing per element test (the tracer runs rays in parallel, hence per thread).
*/
private static final class Scratch { private static final class Scratch {
int n; int n;
Element[] element = new Element[16]; Element[] element = new Element[16];
@@ -44,8 +39,8 @@ public final class ElementIntersector {
double hitT, hitS, hitT2, hitNx, hitNy, hitNz; double hitT, hitS, hitT2, hitNx, hitNy, hitNz;
Direction hitDir; Direction hitDir;
// Same ordering as before (depth; coplanar within 1e-4: later elements first) over candidate indices. // Depth; coplanar within 1e-4: later elements first. Not transitive, so the tie order depends on the sort
// Sorted with the same TimSort as the former List<Candidate>.sort, so ties resolve identically. // algorithm: keep Arrays.sort over objects (TimSort).
final Comparator<Integer> byDepth = (a, b) -> { final Comparator<Integer> byDepth = (a, b) -> {
if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]); if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]);
return Integer.compare(order[b], order[a]); return Integer.compare(order[b], order[a]);
@@ -94,29 +89,29 @@ public final class ElementIntersector {
double[] v = sc.v; double[] v = sc.v;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
Element element = elements.get(i); Element element = elements.get(i);
boolean hit; Element e = element;
Element e; double eox = ox, eoy = oy, eoz = oz, edx = dx, edy = dy, edz = dz;
if (element.unrotated != null) { if (element.unrotated != null) {
// Blockstate rotation: hit the model-space element with the inverse-rotated ray (same t: rigid // 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). // transform), so faces and UVs stay bound to the model like vanilla's FaceBakery.
e = element.unrotated; e = element.unrotated;
unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true); unrotate(v, ox, oy, oz, element.xSteps, element.ySteps, true);
double mox = v[0], moy = v[1], moz = v[2]; eox = v[0];
eoy = v[1];
eoz = v[2];
unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false); unrotate(v, dx, dy, dz, element.xSteps, element.ySteps, false);
hit = e.isAxisAligned() edx = v[0];
? slab(sc, mox, moy, moz, v[0], v[1], v[2], e.from, e.to, null) edy = v[1];
: intersectObb(sc, e, mox, moy, moz, v[0], v[1], v[2]); edz = v[2];
if (hit) { }
rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false); boolean hit = e.isAxisAligned()
sc.hitNx = v[0]; ? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
sc.hitNy = v[1]; : intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
sc.hitNz = v[2]; if (hit && element.unrotated != null) {
} rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
} else { sc.hitNx = v[0];
e = element; sc.hitNy = v[1];
hit = e.isAxisAligned() sc.hitNz = v[2];
? slab(sc, ox, oy, oz, dx, dy, dz, e.from, e.to, null)
: intersectObb(sc, e, ox, oy, oz, dx, dy, dz);
} }
if (!hit || !(sc.hitT > minT)) continue; if (!hit || !(sc.hitT > minT)) continue;
int k = sc.n; int k = sc.n;
@@ -135,9 +130,8 @@ public final class ElementIntersector {
int n = sc.n; int n = sc.n;
if (n == 0) return null; if (n == 0) return null;
Integer[] idx = sc.idx; Integer[] idx = sc.idx;
for (int k = 0; k < n; k++) idx[k] = k; // autoboxing hits the Integer cache for k < 128 for (int k = 0; k < n; k++) idx[k] = k; // Integer cache for k < 128
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's // Coplanar faces: later elements first, like vanilla's draw order (overlays such as the grass side tint).
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face.
if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth); if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth);
for (int j = 0; j < n; j++) { for (int j = 0; j < n; j++) {
@@ -156,9 +150,8 @@ public final class ElementIntersector {
} }
/** /**
* Rotated element: transform the ray into the element's local (unrotated, unscaled) frame, then slab-test. * Rotated element: slab test in the element's local (unrotated, unscaled) frame. Vanilla bakes
* Vanilla bakes {@code origin + R·S·(p - origin)} (S = rescale, see {@link Element#invScale}); the inverse is * {@code origin + R·S·(p - origin)} (S = rescale); the inverse is affine, so t stays the block-space one.
* affine, so the ray parameter t stays the block-space one.
*/ */
private static boolean intersectObb( private static boolean intersectObb(
Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) { Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
@@ -192,7 +185,10 @@ public final class ElementIntersector {
} }
} }
/** Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. */ /**
* Slab test; on a hit the entry (or, from inside, exit) face is left in the scratch's hit fields. {@code obb}:
* the rotated element whose local frame the ray is in (normal rotated back), else null.
*/
private static boolean slab( private static boolean slab(
Scratch sc, Scratch sc,
double ox, double ox,
@@ -204,14 +200,6 @@ public final class ElementIntersector {
double[] from, double[] from,
double[] to, double[] to,
Element obb) { 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;
boolean negFace = false; // entered through the low-coordinate face
double[] o = sc.o, d = sc.d; double[] o = sc.o, d = sc.d;
o[0] = ox; o[0] = ox;
o[1] = oy; o[1] = oy;
@@ -219,6 +207,12 @@ public final class ElementIntersector {
d[0] = dx; d[0] = dx;
d[1] = dy; d[1] = dy;
d[2] = dz; d[2] = dz;
if (from[0] > to[0] || from[1] > to[1] || from[2] > to[2]) return invertedBox(sc, from, to, obb);
double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY;
int axis = -1;
boolean negFace = false; // entered through the low-coordinate face
for (int a = 0; a < 3; a++) { for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) { if (Math.abs(d[a]) < EPS) {
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false; if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false;
@@ -247,7 +241,7 @@ public final class ElementIntersector {
double tEntry = tmin; double tEntry = tmin;
if (tEntry < EPS) { if (tEntry < EPS) {
// origin inside the box (e.g. camera within a block): use exit point instead // Origin inside the box (camera within a block): the exit face.
tEntry = tmax; tEntry = tmax;
if (tEntry < EPS) return false; if (tEntry < EPS) return false;
} }
@@ -257,31 +251,10 @@ public final class ElementIntersector {
double pz = o[2] + d[2] * tEntry; double pz = o[2] + d[2] * tEntry;
Direction dir = faceFor(axis, negFace); Direction dir = faceFor(axis, negFace);
if (obb != null) { setNormal(sc, obb, dir.nx, dir.ny, dir.nz);
// 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;
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];
} else {
sc.hitNx = dir.nx;
sc.hitNy = dir.ny;
sc.hitNz = dir.nz;
}
double fracX = frac(px, from[0], to[0]); double fracX = frac(px, from[0], to[0]);
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]);
sc.hitT = tEntry; sc.hitT = tEntry;
sc.hitDir = dir; sc.hitDir = dir;
sc.hitS = faceS(dir, fracX, fracY, fracZ); sc.hitS = faceS(dir, fracX, fracY, fracZ);
@@ -294,26 +267,11 @@ public final class ElementIntersector {
* bottom): vanilla places the WEST/DOWN/NORTH face at {@code from} and EAST/UP/SOUTH at {@code to} of its axis, * 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 * 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; * 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). * the nearest visible one wins. Texture coordinates follow the mirrored corners (signed span). The ray is in
* the scratch's {@code o}/{@code d}.
*/ */
private static boolean invertedBox( private static boolean invertedBox(Scratch sc, double[] from, double[] to, Element obb) {
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; 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; double best = Double.POSITIVE_INFINITY;
Direction bestDir = null; Direction bestDir = null;
double bestSign = 0; double bestSign = 0;
@@ -335,36 +293,44 @@ public final class ElementIntersector {
} }
if (bestDir == null) return false; if (bestDir == null) return false;
int a = bestDir.nx != 0 ? 0 : bestDir.ny != 0 ? 1 : 2; 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; setNormal(sc, obb, a == 0 ? bestSign : 0, a == 1 ? bestSign : 0, a == 2 ? bestSign : 0);
if (obb != null) { double fx = frac(o[0] + d[0] * best, from[0], to[0]);
double[] v = sc.v; double fy = frac(o[1] + d[1] * best, from[1], to[1]);
double[] m = obb.rotMatrix; double fz = frac(o[2] + d[2] * best, from[2], to[2]);
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.hitT = best;
sc.hitDir = bestDir; sc.hitDir = bestDir;
sc.hitNx = nx;
sc.hitNy = ny;
sc.hitNz = nz;
sc.hitS = faceS(bestDir, fx, fy, fz); sc.hitS = faceS(bestDir, fx, fy, fz);
sc.hitT2 = faceT(bestDir, fy, fz); sc.hitT2 = faceT(bestDir, fy, fz);
return true; return true;
} }
/**
* The hit normal (local axis normal of the element's frame) in block space: rotated back for {@code obb}. The
* rescale is diagonal in the local frame, so an axis normal keeps its direction (n = R·n_local).
*/
private static void setNormal(Scratch sc, Element obb, double nx, double ny, double nz) {
if (obb == null) {
sc.hitNx = nx;
sc.hitNy = ny;
sc.hitNz = nz;
return;
}
double[] m = obb.rotMatrix;
if (m != null) {
sc.hitNx = m[0] * nx + m[1] * ny + m[2] * nz;
sc.hitNy = m[3] * nx + m[4] * ny + m[5] * nz;
sc.hitNz = m[6] * nx + m[7] * ny + m[8] * nz;
} else {
double[] v = sc.v;
rotate(v, nx, ny, nz, obb.rotAxis, obb.rotCos, obb.rotSin);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
}
}
/** Position between {@code from} (0) and {@code to} (1), also when {@code from > to}; clamped to 0..1. */ /** 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) { private static double frac(double v, double from, double to) {
double span = to - from; double span = to - from;
if (Math.abs(span) < 1e-6) return 0; if (Math.abs(span) < 1e-6) return 0;
double f = (v - from) / span; double f = (v - from) / span;
@@ -372,10 +338,9 @@ public final class ElementIntersector {
} }
/** /**
* Face-local horizontal coordinate s in 0..1 for a hit at block fractions (fx, fy, fz), matching vanilla's * Face-local horizontal coordinate s in 0..1 for a hit at box fractions (fx, fy, fz), like vanilla's
* {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with (s, t), so north/east * {@code BlockElement.uvsByFace}: north/east faces run towards -x/-z (left to right seen from outside), down runs
* faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards * v towards north. Shared with the entity intersector (box UVs have the same orientation).
* north. Shared with the entity intersector (entity box-UV uses the same orientation).
*/ */
public static double faceS(Direction dir, double fx, double fy, double fz) { public static double faceS(Direction dir, double fx, double fy, double fz) {
return switch (dir) { return switch (dir) {
@@ -403,14 +368,7 @@ public final class ElementIntersector {
}; };
} }
private static double frac(double v, double lo, double hi) { /** Rotates (x, y, z) about axis 0/1/2 (x/y/z) by the angle with the given cos/sin into out. */
double span = hi - lo;
if (span < 1e-6) return 0;
double f = (v - lo) / span;
return f < 0 ? 0 : Math.min(f, 1);
}
/** Rotate (x,y,z) around the given axis (0=x,1=y,2=z) by the angle with the given cos/sin, into out. */
private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) { private static void rotate(double[] out, double x, double y, double z, int axis, double cos, double sin) {
switch (axis) { switch (axis) {
case 0 -> { case 0 -> {
@@ -431,7 +389,7 @@ public final class ElementIntersector {
} }
} }
/** {@link Element#rotate} into out (same arithmetic, no allocation). */ /** {@link Element#rotate} into out (same arithmetic, without allocating). */
private static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) { private static void rotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
double c = point ? 0.5 : 0; double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c; double dx = x - c, dy = y - c, dz = z - c;
@@ -450,7 +408,7 @@ public final class ElementIntersector {
out[2] = dz + c; out[2] = dz + c;
} }
/** {@link Element#unrotate} into out (same arithmetic, no allocation). */ /** {@link Element#unrotate} into out (same arithmetic, without allocating). */
private static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) { private static void unrotate(double[] out, double x, double y, double z, int xSteps, int ySteps, boolean point) {
rotate(out, x, y, z, 0, (4 - ySteps) % 4, point); rotate(out, x, y, z, 0, (4 - ySteps) % 4, point);
rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point); rotate(out, out[0], out[1], out[2], (4 - xSteps) % 4, 0, point);
@@ -3,10 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.raytrace;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal, * A ray hit on block or entity geometry: world-space point and normal, sampled ARGB texel (before shading/tint),
* the sampled ARGB color (before shading/tinting), the face's tint index ({@code -1} = none), whether the * tint index (-1 = none), additive (emissive energy swirl, added to what lies behind) and unshaded (no cardinal
* surface is drawn additively (emissive energy-swirl layers: its colour is added to what lies behind) and whether * lighting: eyes, breeze wind).
* it skips directional face shading (entity layers without cardinal lighting: eyes, breeze wind).
*/ */
public record FaceHit( public record FaceHit(
double t, Vector point, Vector normal, int color, int tintIndex, boolean additive, boolean unshaded) {} double t, Vector point, Vector normal, int color, int tintIndex, boolean additive, boolean unshaded) {}
@@ -18,26 +18,21 @@ import org.bukkit.block.data.Levelled;
import org.bukkit.block.data.Waterlogged; import org.bukkit.block.data.Waterlogged;
/** /**
* Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): fluids are not block models but * Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): not block models but polygons built
* polygons built per cell from the fluid state and its neighbours: a top surface whose four corner heights come * per cell from the fluid state and its neighbours. The top surface has four corner heights (amount / 9, full
* from the cell's level (amount / 9, source 8/9, full when the same fluid lies above) averaged with the * under the same fluid, averaged with the neighbours like {@code calculateAverageHeight}) split into the triangles
* neighbours ({@code calculateAverageHeight}, split into the two triangles NW-SW-SE / NW-SE-NE), side faces * NW-SW-SE / NW-SE-NE; side faces against every neighbour that is not the same fluid and does not cover the face;
* against every neighbour that is not the same fluid and does not cover the face, and a bottom face. Top and * a bottom face. Top and sides are double-sided (the top only if {@code shouldRenderBackwardUpFace}), except side
* side faces are double-sided (the top only when {@code shouldRenderBackwardUpFace}); side faces against * faces against {@code HalfTransparentBlock}/{@code LeavesBlock}, which use the overlay texture one-sided; the
* {@code HalfTransparentBlock}/{@code LeavesBlock} use the water overlay texture and are one-sided; the bottom is * bottom is seen from below only. Waterlogged blocks and seagrass/kelp/bubble columns hold a water source.
* one-sided (seen from below). Waterlogged blocks and seagrass/kelp/bubble columns hold a water source.
*
* <p>The tracer asks for the nearest drawn fluid polygon a ray crosses inside one cell ({@link #nearest}); the
* caller tints/lights it and blends it as a translucent layer (water texture alpha 180) or ends the ray on it
* (lava, opaque).
*/ */
public final class FluidRenderer { public final class FluidRenderer {
public static final int NONE = 0; public static final int NONE = 0;
public static final int WATER = 1; static final int WATER = 1;
public static final int LAVA = 2; static final int LAVA = 2;
// Vanilla FluidRenderer insets: faces 0.001 inside the cell, the top 0.001 below the computed height. // Vanilla insets: faces 0.001 inside the cell, the top 0.001 below the computed height.
private static final float OFFS = 0.001F; private static final float OFFS = 0.001F;
private static final double EPS = 1e-9; private static final double EPS = 1e-9;
@@ -47,13 +42,31 @@ public final class FluidRenderer {
BlockData at(int x, int y, int z); BlockData at(int x, int y, int z);
} }
/** Blocks whose fluid state is always a water source (LiquidBlockContainer without a waterlogged property). */ // LiquidBlockContainers without a waterlogged property: always a water source.
private static final EnumSet<Material> ALWAYS_WATER = private static final EnumSet<Material> ALWAYS_WATER =
materials("SEAGRASS", "TALL_SEAGRASS", "KELP", "KELP_PLANT", "BUBBLE_COLUMN"); materials("SEAGRASS", "TALL_SEAGRASS", "KELP", "KELP_PLANT", "BUBBLE_COLUMN");
// Vanilla HalfTransparentBlock / LeavesBlock: water draws its side faces against them with the overlay texture, // Vanilla HalfTransparentBlock / LeavesBlock (FluidRenderer isOverlay). Blue ice too, but it occludes anyway.
// one-sided (FluidRenderer isOverlay). Blue ice is one too but occludes (its face is culled anyway). private static final EnumSet<Material> OVERLAY_NEIGHBOURS = EnumSet.noneOf(Material.class);
private static final EnumSet<Material> OVERLAY_NEIGHBOURS = buildOverlayNeighbours(); // Vanilla tag blocks_fluid_flow = #blocks_motion (Material.isSolid) + #all_signs.
private static final EnumSet<Material> SIGNS = EnumSet.noneOf(Material.class);
static {
for (Material m : Material.values()) {
if (m.isLegacy()) continue;
String n = m.name();
if (n.equals("GLASS")
|| n.equals("TINTED_GLASS")
|| n.endsWith("_STAINED_GLASS")
|| n.equals("ICE")
|| n.equals("FROSTED_ICE")
|| n.equals("BLUE_ICE")
|| n.equals("SLIME_BLOCK")
|| n.equals("HONEY_BLOCK")
|| n.endsWith("_LEAVES")) OVERLAY_NEIGHBOURS.add(m);
if (n.endsWith("_SIGN")) SIGNS.add(m);
}
}
private static EnumSet<Material> materials(String... names) { private static EnumSet<Material> materials(String... names) {
EnumSet<Material> set = EnumSet.noneOf(Material.class); EnumSet<Material> set = EnumSet.noneOf(Material.class);
@@ -67,30 +80,12 @@ public final class FluidRenderer {
return set; return set;
} }
private static EnumSet<Material> buildOverlayNeighbours() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (Material m : Material.values()) {
if (m.isLegacy()) continue;
String n = m.name();
if (n.equals("GLASS")
|| n.equals("TINTED_GLASS")
|| n.endsWith("_STAINED_GLASS")
|| n.equals("ICE")
|| n.equals("FROSTED_ICE")
|| n.equals("BLUE_ICE")
|| n.equals("SLIME_BLOCK")
|| n.equals("HONEY_BLOCK")
|| n.endsWith("_LEAVES")) set.add(m);
}
return set;
}
// --- fluid state: type (bits 0-1), amount 1..8 (bits 2-5), falling (bit 6), source (bit 7) ------------- // --- fluid state: type (bits 0-1), amount 1..8 (bits 2-5), falling (bit 6), source (bit 7) -------------
private static final int WATER_SOURCE = WATER | 8 << 2 | 1 << 7; private static final int WATER_SOURCE = WATER | 8 << 2 | 1 << 7;
/** The fluid state of a block (vanilla {@code BlockState.getFluidState}), packed; 0 = no fluid. */ /** The fluid state of a block (vanilla {@code BlockState.getFluidState}), packed; 0 = no fluid. */
public static int state(BlockData data) { static int state(BlockData data) {
Material m = data.getMaterial(); Material m = data.getMaterial();
if (m == Material.AIR) return 0; if (m == Material.AIR) return 0;
if (m == Material.WATER || m == Material.LAVA) { if (m == Material.WATER || m == Material.LAVA) {
@@ -106,7 +101,7 @@ public final class FluidRenderer {
return 0; return 0;
} }
public static int type(int state) { static int type(int state) {
return state & 3; return state & 3;
} }
@@ -125,8 +120,8 @@ public final class FluidRenderer {
/** /**
* Which fluid the camera at {@code (x, y, z)} is in (vanilla {@code Camera.getFluidInCamera}): the eye block's * Which fluid the camera at {@code (x, y, z)} is in (vanilla {@code Camera.getFluidInCamera}): the eye block's
* fluid, when the eye is below its camera height ({@code FluidState.getHeightForCamera}: 1 for a source under a * fluid when the eye is below its {@code FluidState.getHeightForCamera} (1 under the same fluid or for a source
* block with a sturdy bottom face or with the same fluid above, else amount / 9). * under a sturdy bottom face, else amount / 9).
*/ */
public static int cameraFluid(BlockLookup level, double x, double y, double z) { public static int cameraFluid(BlockLookup level, double x, double y, double z) {
int bx = (int) Math.floor(x), by = (int) Math.floor(y), bz = (int) Math.floor(z); int bx = (int) Math.floor(x), by = (int) Math.floor(y), bz = (int) Math.floor(z);
@@ -134,14 +129,7 @@ public final class FluidRenderer {
int type = type(s); int type = type(s);
if (type == NONE) return NONE; if (type == NONE) return NONE;
BlockData above = level.at(bx, by + 1, bz); BlockData above = level.at(bx, by + 1, bz);
float h; float h = type(state(above)) == type || (source(s) && sturdyBottom(above)) ? 1 : ownHeight(s);
if (type(state(above)) == type) {
h = 1;
} else if (source(s) && sturdyBottom(above)) {
h = 1;
} else {
h = ownHeight(s);
}
double rel = y - by; double rel = y - by;
// Water: strictly below the surface; lava (near-plane check): at or below it. // Water: strictly below the surface; lava (near-plane check): at or below it.
return type == WATER ? (rel < h ? WATER : NONE) : (rel <= h ? LAVA : NONE); return type == WATER ? (rel < h ? WATER : NONE) : (rel <= h ? LAVA : NONE);
@@ -159,11 +147,11 @@ public final class FluidRenderer {
// --- sky light under water ---------------------------------------------------------------------------- // --- sky light under water ----------------------------------------------------------------------------
/** /**
* Vanilla sky light level of the cell (x, y, z) when water lies above it: 15 minus one level per block from * Vanilla sky light level of the cell (x, y, z) under water: 15 minus one per block from this cell up to the
* this cell up to the topmost water block of the column (water costs one level, non-occluding blocks below the * topmost water block of the column (water and non-occluding blocks below the surface cost one level each).
* surface such as glass at least one). Cells without water above them (in the next 15 blocks) get 15. * 15 without water in the next 15 blocks.
*/ */
public static int skyLevel(BlockLookup level, int x, int y, int z) { static int skyLevel(BlockLookup level, int x, int y, int z) {
int water = 0; int water = 0;
for (int i = 0; i < 15; i++) { for (int i = 0; i < 15; i++) {
BlockData d = level.at(x, y + i, z); BlockData d = level.at(x, y + i, z);
@@ -181,12 +169,13 @@ public final class FluidRenderer {
private final BlockModelRegistry registry; private final BlockModelRegistry registry;
private final int[][] waterStill, waterFlow, waterOverlay, lavaStill, lavaFlow; private final int[][] waterStill, waterFlow, waterOverlay, lavaStill, lavaFlow;
// Bit per Direction: the block model covers that whole cell face with opaque texels (vanilla face occlusion // Bit per Direction: the model covers that whole cell face with opaque texels (approximates vanilla's face
// shape of blocks that can occlude). Identity keys (ResolvedModel is cached per BlockData). // occlusion shape). Identity keys: ResolvedModel is cached per BlockData.
private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>(); private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>();
public FluidRenderer(BlockModelRegistry registry, TextureCache textures) { FluidRenderer(BlockModelRegistry registry) {
this.registry = registry; this.registry = registry;
TextureCache textures = registry.textures();
this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1); this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1);
this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA); this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA);
this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5); this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5);
@@ -210,9 +199,12 @@ public final class FluidRenderer {
// --- face occlusion ------------------------------------------------------------------------------------ // --- face occlusion ------------------------------------------------------------------------------------
private int fullFaces(BlockData data) { private int fullFaces(BlockData data) {
Material m = data.getMaterial(); if (data.getMaterial().isAir()) return 0;
if (m.isAir()) return 0; return fullFaces(registry.get(data));
return fullFaces.computeIfAbsent(registry.get(data), FluidRenderer::computeFullFaces); }
private int fullFaces(ResolvedModel model) {
return fullFaces.computeIfAbsent(model, FluidRenderer::computeFullFaces);
} }
/** Faces fully covered by opaque, unrotated element faces on the cell boundary (8x8 sample grid). */ /** Faces fully covered by opaque, unrotated element faces on the cell boundary (8x8 sample grid). */
@@ -253,14 +245,18 @@ public final class FluidRenderer {
return true; return true;
} }
/** FluidRenderer.isFaceOccludedByState for a neighbour/self block whose face points at the fluid. */ /** FluidRenderer.isFaceOccludedByState for a neighbour block whose face points at the fluid. */
private boolean occludes(BlockData block, Direction face) { private boolean occludes(BlockData block, Direction face) {
return (fullFaces(block) & 1 << face.ordinal()) != 0; return covers(fullFaces(block), face);
}
private static boolean covers(int fullFaces, Direction face) {
return (fullFaces & 1 << face.ordinal()) != 0;
} }
// --- heights ------------------------------------------------------------------------------------------- // --- heights -------------------------------------------------------------------------------------------
/** FluidRenderer.getHeight: 1 under the same fluid, own height in it, 0 next to air etc., -1 next to solids. */ /** FluidRenderer.getHeight: 1 under the same fluid, own height in it, 0 for non-solid blocks, -1 for solids. */
private static float height(BlockLookup level, int type, int x, int y, int z, BlockData data, int state) { private static float height(BlockLookup level, int type, int x, int y, int z, BlockData data, int state) {
if (type(state) == type) { if (type(state) == type) {
return type(state(level.at(x, y + 1, z))) == type ? 1.0F : ownHeight(state); return type(state(level.at(x, y + 1, z))) == type ? 1.0F : ownHeight(state);
@@ -273,42 +269,36 @@ public final class FluidRenderer {
return height(level, type, x, y, z, d, state(d)); return height(level, type, x, y, z, d, state(d));
} }
/** FluidRenderer.calculateAverageHeight (weights: 10 for heights >= 0.8, 1 below, solids (-1) none). */
private static float averageHeight( private static float averageHeight(
BlockLookup level, int type, float self, float h2, float h1, int cx, int cy, int cz) { BlockLookup level, int type, float self, float h2, float h1, int cx, int cy, int cz) {
if (h1 >= 1.0F || h2 >= 1.0F) return 1.0F; if (h1 >= 1.0F || h2 >= 1.0F) return 1.0F;
float[] w = new float[2]; float corner = -1.0F;
if (h1 > 0.0F || h2 > 0.0F) { if (h1 > 0.0F || h2 > 0.0F) {
float corner = height(level, type, cx, cy, cz); corner = height(level, type, cx, cy, cz);
if (corner >= 1.0F) return 1.0F; if (corner >= 1.0F) return 1.0F;
addWeighted(w, corner);
} }
addWeighted(w, self); float wc = weight(corner), ws = weight(self), w1 = weight(h1), w2 = weight(h2);
addWeighted(w, h1); // Same summation order as vanilla's running sums.
addWeighted(w, h2); return (0.0F + corner * wc + self * ws + h1 * w1 + h2 * w2) / (0.0F + wc + ws + w1 + w2);
return w[0] / w[1];
} }
private static void addWeighted(float[] w, float h) { private static float weight(float h) {
if (h >= 0.8F) { return h >= 0.8F ? 10.0F : h >= 0.0F ? 1.0F : 0.0F;
w[0] += h * 10.0F;
w[1] += 10.0F;
} else if (h >= 0.0F) {
w[0] += h;
w[1]++;
}
} }
// --- flow (top texture direction) --------------------------------------------------------------------- // --- flow (top texture direction) ---------------------------------------------------------------------
private static final int[][] HORIZONTAL = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}};
/** /**
* FlowingFluid.getFlow, horizontal part ({x, z}, not normalised; the falling tweak only adds a y component, * Texture angle of the flowing top sprite from FlowingFluid.getFlow (horizontal part; the falling tweak only adds
* which does not change the texture angle). {0, 0} = still texture. * y), or NaN for the still texture.
*/ */
private static double[] flow(BlockLookup level, int type, int x, int y, int z, int state) { private static float flowAngle(BlockLookup level, int type, int x, int y, int z, int state) {
double fx = 0, fz = 0; double fx = 0, fz = 0;
float own = ownHeight(state); float own = ownHeight(state);
int[][] dirs = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}}; for (int[] d : HORIZONTAL) {
for (int[] d : dirs) {
BlockData nd = level.at(x + d[0], y, z + d[1]); BlockData nd = level.at(x + d[0], y, z + d[1]);
int ns = state(nd); int ns = state(nd);
if (!affectsFlow(type, ns)) continue; if (!affectsFlow(type, ns)) continue;
@@ -331,8 +321,8 @@ public final class FluidRenderer {
} }
} }
// Vec3.normalize: below 1e-5 the vector is zero (still texture). // Vec3.normalize: below 1e-5 the vector is zero (still texture).
if (Math.sqrt(fx * fx + fz * fz) < 1.0E-5) return new double[] {0, 0}; if (Math.sqrt(fx * fx + fz * fz) < 1.0E-5) return Float.NaN;
return new double[] {fx, fz}; return (float) Math.atan2(fz, fx) - (float) (Math.PI / 2);
} }
private static boolean affectsFlow(int type, int neighbourState) { private static boolean affectsFlow(int type, int neighbourState) {
@@ -340,10 +330,9 @@ public final class FluidRenderer {
return t == NONE || t == type; return t == NONE || t == type;
} }
/** Vanilla tag blocks_fluid_flow (= #blocks_motion + #all_signs). */
private static boolean blocksFluidFlow(BlockData data) { private static boolean blocksFluidFlow(BlockData data) {
Material m = data.getMaterial(); Material m = data.getMaterial();
return m.isSolid() || m.name().endsWith("_SIGN"); return m.isSolid() || SIGNS.contains(m);
} }
/** FluidState.shouldRenderBackwardUpFace: some block of the 3x3 above is neither the fluid nor solid-render. */ /** FluidState.shouldRenderBackwardUpFace: some block of the 3x3 above is neither the fluid nor solid-render. */
@@ -359,27 +348,25 @@ public final class FluidRenderer {
// --- ray crossing -------------------------------------------------------------------------------------- // --- ray crossing --------------------------------------------------------------------------------------
/** A drawn fluid polygon crossed by a ray: texel (ARGB), shade, face, outward normal and side. */ /** A drawn fluid polygon crossed by a ray. */
public static final class Crossing { static final class Crossing {
public double t; double t;
public int texel; int texel;
public double shade; boolean entering; // the ray comes from outside the fluid (front side)
public int type; double nx, ny, nz; // outward unit normal
public boolean entering; // the ray comes from outside the fluid (front side) boolean bottom; // the bottom face, lit by the cell below
public double nx, ny, nz; // outward normal (unit)
public int lightX, lightY, lightZ; // cell whose light (max with the one above) lights the face
} }
/** /**
* The nearest fluid polygon of cell (bx, by, bz) (holding {@code data} with fluid {@code state}) crossed by * The nearest drawn fluid polygon of cell (bx, by, bz) (block model {@code model}, fluid {@code state}) crossed
* the ray {@code o + t·d} with {@code t > minT}, or null. * by the ray {@code o + t·d} with {@code t > minT}, or null.
*/ */
public Crossing nearest( Crossing nearest(
BlockLookup level, BlockLookup level,
int bx, int bx,
int by, int by,
int bz, int bz,
BlockData data, ResolvedModel model,
int state, int state,
double ox, double ox,
double oy, double oy,
@@ -392,22 +379,22 @@ public final class FluidRenderer {
BlockData down = level.at(bx, by - 1, bz), up = level.at(bx, by + 1, bz); BlockData down = level.at(bx, by - 1, bz), up = level.at(bx, by + 1, bz);
BlockData north = level.at(bx, by, bz - 1), south = level.at(bx, by, bz + 1); BlockData north = level.at(bx, by, bz - 1), south = level.at(bx, by, bz + 1);
BlockData west = level.at(bx - 1, by, bz), east = level.at(bx + 1, by, bz); BlockData west = level.at(bx - 1, by, bz), east = level.at(bx + 1, by, bz);
int sDown = state(down), sUp = state(up), sNorth = state(north), sSouth = state(south); int sNorth = state(north), sSouth = state(south), sWest = state(west), sEast = state(east);
int sWest = state(west), sEast = state(east); boolean sameUp = type(state(up)) == type, sameDown = type(state(down)) == type;
boolean sameUp = type(sUp) == type, sameDown = type(sDown) == type;
boolean sameN = type(sNorth) == type, sameS = type(sSouth) == type; boolean sameN = type(sNorth) == type, sameS = type(sSouth) == type;
boolean sameW = type(sWest) == type, sameE = type(sEast) == type; boolean sameW = type(sWest) == type, sameE = type(sEast) == type;
if (sameUp && sameDown && sameN && sameS && sameW && sameE) return null; // inside a body of fluid // Own faces covering the cell faces; only looked up when a face other than the top may be drawn.
int selfFaces = sameDown && sameN && sameS && sameW && sameE ? 0 : fullFaces(model);
boolean renderUp = !sameUp; boolean renderUp = !sameUp;
boolean renderDown = !sameDown && !occludes(data, Direction.DOWN) && !occludes(down, Direction.UP); boolean renderDown = !sameDown && !covers(selfFaces, Direction.DOWN) && !occludes(down, Direction.UP);
boolean renderN = !sameN && !occludes(data, Direction.NORTH); boolean renderN = !sameN && !covers(selfFaces, Direction.NORTH);
boolean renderS = !sameS && !occludes(data, Direction.SOUTH); boolean renderS = !sameS && !covers(selfFaces, Direction.SOUTH);
boolean renderW = !sameW && !occludes(data, Direction.WEST); boolean renderW = !sameW && !covers(selfFaces, Direction.WEST);
boolean renderE = !sameE && !occludes(data, Direction.EAST); boolean renderE = !sameE && !covers(selfFaces, Direction.EAST);
if (!(renderUp || renderDown || renderN || renderS || renderW || renderE)) return null; if (!(renderUp || renderDown || renderN || renderS || renderW || renderE)) return null;
float self = height(level, type, bx, by, bz, data, state); float self = sameUp ? 1.0F : ownHeight(state);
float hNE, hNW, hSE, hSW; float hNE, hNW, hSE, hSW;
if (self >= 1.0F) { if (self >= 1.0F) {
hNE = hNW = hSE = hSW = 1.0F; hNE = hNW = hSE = hSW = 1.0F;
@@ -461,7 +448,7 @@ public final class FluidRenderer {
double x = lx + dx * t, z = lz + dz * t; double x = lx + dx * t, z = lz + dz * t;
if (x < -EPS || x > 1 + EPS || z < -EPS || z > 1 + EPS) continue; if (x < -EPS || x > 1 + EPS || z < -EPS || z > 1 + EPS) continue;
if (tri == 0 ? z < x - EPS : z >= x + EPS) continue; if (tri == 0 ? z < x - EPS : z >= x + EPS) continue;
boolean entering = denom < 0; // dot(d, n) = dy - b·dx - c·dz boolean entering = denom < 0;
if (!entering) { if (!entering) {
if (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz); if (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz);
if (!backUp) continue; if (!backUp) continue;
@@ -479,7 +466,6 @@ public final class FluidRenderer {
} }
} }
if (renderDown && dy > 0) { if (renderDown && dy > 0) {
// One-sided, seen from below only.
double t = (bottom - ly) / dy; double t = (bottom - ly) / dy;
if (t > minT && t < bestT) { if (t > minT && t < bestT) {
double x = lx + dx * t, z = lz + dz * t; double x = lx + dx * t, z = lz + dz * t;
@@ -496,73 +482,60 @@ public final class FluidRenderer {
} }
} }
} }
// Side faces: plane, outward normal, edge heights from vertex 0 to vertex 1 (vanilla order) along s. // Side faces: the neighbour's face towards the fluid, plane, outward sign and the edge heights from vertex 0
// to vertex 1 in vanilla's order (N: west->east, S: east->west, W: south->north, E: north->south).
for (int face = 2; face <= 5; face++) { for (int face = 2; face <= 5; face++) {
boolean render; boolean render;
BlockData neighbour; BlockData neighbour;
double plane; Direction neighbourFace;
float h0, h1; float h0, h1;
switch (face) { switch (face) {
case 2 -> { case 2 -> {
render = renderN; render = renderN;
neighbour = north; neighbour = north;
plane = OFFS; neighbourFace = Direction.SOUTH;
h0 = hNW; h0 = hNW;
h1 = hNE; h1 = hNE;
} }
case 3 -> { case 3 -> {
render = renderS; render = renderS;
neighbour = south; neighbour = south;
plane = 1 - OFFS; neighbourFace = Direction.NORTH;
h0 = hSE; h0 = hSE;
h1 = hSW; h1 = hSW;
} }
case 4 -> { case 4 -> {
render = renderW; render = renderW;
neighbour = west; neighbour = west;
plane = OFFS; neighbourFace = Direction.EAST;
h0 = hSW; h0 = hSW;
h1 = hNW; h1 = hNW;
} }
default -> { default -> {
render = renderE; render = renderE;
neighbour = east; neighbour = east;
plane = 1 - OFFS; neighbourFace = Direction.WEST;
h0 = hNE; h0 = hNE;
h1 = hSE; h1 = hSE;
} }
} }
if (!render) continue; if (!render) continue;
boolean alongX = face == 2 || face == 3; boolean alongX = face == 2 || face == 3;
boolean negative = face == 2 || face == 4;
double plane = negative ? OFFS : 1 - OFFS;
double dAxis = alongX ? dz : dx; double dAxis = alongX ? dz : dx;
if (Math.abs(dAxis) < 1e-12) continue; if (Math.abs(dAxis) < 1e-12) continue;
double t = (plane - (alongX ? lz : lx)) / dAxis; double t = (plane - (alongX ? lz : lx)) / dAxis;
if (!(t > minT) || t >= bestT) continue; if (!(t > minT) || t >= bestT) continue;
double outward = face == 2 || face == 4 ? -1 : 1; double outward = negative ? -1 : 1;
boolean entering = dAxis * outward < 0; boolean entering = dAxis * outward < 0;
Direction toNeighbour = if (occludes(neighbour, neighbourFace)) continue;
switch (face) {
case 2 -> Direction.NORTH;
case 3 -> Direction.SOUTH;
case 4 -> Direction.WEST;
default -> Direction.EAST;
};
// Covered by the neighbour's face towards the fluid (full block face; partial shapes never cover the
// whole fluid face here).
if (occludes(neighbour, opposite(toNeighbour))) continue;
boolean overlay = type == WATER && OVERLAY_NEIGHBOURS.contains(neighbour.getMaterial()); boolean overlay = type == WATER && OVERLAY_NEIGHBOURS.contains(neighbour.getMaterial());
if (!entering && overlay) continue; if (!entering && overlay) continue;
double x = lx + dx * t, y = ly + dy * t, z = lz + dz * t; double x = lx + dx * t, y = ly + dy * t, z = lz + dz * t;
double along = alongX ? x : z; double along = alongX ? x : z;
if (along < -EPS || along > 1 + EPS) continue; if (along < -EPS || along > 1 + EPS) continue;
// s: position from vertex 0 to vertex 1 (N: west->east, S: east->west, W: south->north, E: north->south) double s = alongX ? (negative ? x : 1 - x) : (negative ? 1 - z : z);
double s =
switch (face) {
case 2 -> x;
case 3 -> 1 - x;
case 4 -> 1 - z;
default -> z;
};
double top = h0 + (h1 - h0) * s; double top = h0 + (h1 - h0) * s;
if (y < bottom - EPS || y > top + EPS) continue; if (y < bottom - EPS || y > top + EPS) continue;
bestT = t; bestT = t;
@@ -581,51 +554,29 @@ public final class FluidRenderer {
Crossing best = new Crossing(); Crossing best = new Crossing();
best.t = bestT; best.t = bestT;
best.type = type;
best.entering = bestEntering; best.entering = bestEntering;
best.nx = bestNx; best.nx = bestNx;
best.ny = bestNy; best.ny = bestNy;
best.nz = bestNz; best.nz = bestNz;
best.lightX = bx; best.bottom = bestFace == 1;
best.lightY = bestFace == 1 ? by - 1 : by;
best.lightZ = bz;
boolean water = type == WATER; boolean water = type == WATER;
switch (bestFace) { int[][] still = water ? waterStill : lavaStill, flow = water ? waterFlow : lavaFlow;
case 0 -> { if (bestFace == 0) {
double[] f = flow(level, type, bx, by, bz, state); float angle = flowAngle(level, type, bx, by, bz, state);
if (f[0] == 0 && f[1] == 0) { if (Float.isNaN(angle)) {
best.texel = sample(water ? waterStill : lavaStill, hitX, hitZ); best.texel = sample(still, hitX, hitZ);
} else { } else {
// Flowing sprite turned to the flow (vanilla UVs at the corners; affine over the cell). // Flowing sprite turned to the flow (vanilla UVs at the corners; affine over the cell).
float angle = (float) Math.atan2(f[1], f[0]) - (float) (Math.PI / 2); double sn = Math.sin(angle) * 0.25F, cs = Math.cos(angle) * 0.25F;
double sn = Math.sin(angle) * 0.25F, cs = Math.cos(angle) * 0.25F; double u = 0.5 - cs - sn + 2 * cs * hitX + 2 * sn * hitZ;
double u = 0.5 - cs - sn + 2 * cs * hitX + 2 * sn * hitZ; double v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ;
double v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ; best.texel = sample(flow, u, v);
best.texel = sample(water ? waterFlow : lavaFlow, u, v);
}
best.shade = 1.0;
}
case 1 -> {
best.texel = sample(water ? waterStill : lavaStill, hitX, hitZ);
best.shade = 0.5;
}
default -> {
int[][] sprite = sideOverlay ? waterOverlay : water ? waterFlow : lavaFlow;
best.texel = sample(sprite, sideS * 0.5, (1 - hitY) * 0.5);
best.shade = bestFace <= 3 ? 0.8 : 0.6;
} }
} else if (bestFace == 1) {
best.texel = sample(still, hitX, hitZ);
} else {
best.texel = sample(sideOverlay ? waterOverlay : flow, sideS * 0.5, (1 - hitY) * 0.5);
} }
return best; return best;
} }
private static Direction opposite(Direction d) {
return switch (d) {
case NORTH -> Direction.SOUTH;
case SOUTH -> Direction.NORTH;
case WEST -> Direction.EAST;
case EAST -> Direction.WEST;
case UP -> Direction.DOWN;
case DOWN -> Direction.UP;
};
}
} }
@@ -19,25 +19,19 @@ import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the * Traces a single ray against a {@link WorldSnapshot}: block models ({@link ElementIntersector}), fluid polygons
* {@link ElementIntersector} and applying biome tint, directional face shading, alpha blending of * ({@link FluidRenderer}) and entities, with biome tint, face shading, ambient occlusion, alpha blending of
* translucent surfaces, liquid transparency and reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads. * translucent surfaces, fog and water reflection. No Bukkit world access, so it runs on worker threads.
*/ */
public final class SnapshotRaytracer { public final class SnapshotRaytracer {
private static final int BIOME_BLEND_RADIUS = 2; private static final int BIOME_BLEND_RADIUS = 2;
// Distance fog (atmospheric perspective). // Vanilla-style ambient occlusion brightness per occlusion level (0 = most occluded .. 3 = open), kept subtle.
private static final double FOG_START = 128;
private static final double FOG_END = 256;
private static final double FOG_MAX = 0.75;
// Vanilla-style ambient occlusion brightness per occlusion level (0=most occluded .. 3=open). Kept subtle.
private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0}; private static final double[] AO_BRIGHTNESS = {0.55, 0.70, 0.85, 1.0};
// Alpha blending: texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones (stained glass, ice, // Texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones are blended over what lies behind them.
// slime outer layer, text-display backgrounds) are blended over what lies behind them. Bounded so a // Bounded so a stack of glass costs at most a few extra intersection rounds per ray.
// stack of glass costs at most a few extra intersection rounds per ray.
private static final int OPAQUE_ALPHA = 250; private static final int OPAQUE_ALPHA = 250;
private static final int MAX_TRANSLUCENT_LAYERS = 6; private static final int MAX_TRANSLUCENT_LAYERS = 6;
private static final double MIN_TRANSMITTANCE = 0.03; private static final double MIN_TRANSMITTANCE = 0.03;
@@ -45,14 +39,12 @@ public final class SnapshotRaytracer {
// layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind. // layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind.
private static final double ADDITIVE = -1; private static final double ADDITIVE = -1;
// Water reflection (not vanilla; kept as before): weight of the reflected ray on the first water surface hit // Water reflection (not vanilla): weight of the reflected ray on the first water surface entered from the air.
// from above (rays starting in the air only).
private static final double WATER_REFLECTION = 0.10; private static final double WATER_REFLECTION = 0.10;
// Brightness by sky light level (index): vanilla lightmap (lightmap.fsh: level / (4 - 3·level), mixed halfway // Brightness by sky light level: vanilla lightmap.fsh (level / (4 - 3·level), mixed halfway with notGamma at the
// with notGamma = default brightness option 0.5, overworld ambient 0). Sky light loses one level per water // default brightness 0.5, overworld ambient 0). PixelPics has no other light model, so it only matters under
// block (water's light block = 1), so surfaces under water get darker with VERTICAL depth (FluidRenderer // water, where sky light drops by vertical water depth (FluidRenderer.skyLevel). SKY_LIGHT[15] == 1.
// .skyLevel); PixelPics has no other light model, so this only applies under water.
private static final double[] SKY_LIGHT = new double[16]; private static final double[] SKY_LIGHT = new double[16];
static { static {
@@ -80,7 +72,7 @@ public final class SnapshotRaytracer {
double maxDistance, double maxDistance,
int reflectionDepth) { int reflectionDepth) {
this.registry = registry; this.registry = registry;
this.fluids = new FluidRenderer(registry, registry.textures()); this.fluids = new FluidRenderer(registry);
this.tintProvider = tintProvider; this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer; this.skyRenderer = skyRenderer;
this.maxDistance = maxDistance; this.maxDistance = maxDistance;
@@ -88,16 +80,11 @@ public final class SnapshotRaytracer {
this.maxSteps = (int) (maxDistance * 3) + 3; this.maxSteps = (int) (maxDistance * 3) + 3;
} }
/** The fluid the camera at {@code origin} is in and its fog (once per photo, see {@link CameraMedium}). */ /** The medium (air or fluid with its fog) the camera at {@code origin} is in; once per photo. */
public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin) { public CameraMedium cameraMedium(WorldSnapshot snapshot, Vector origin) {
return CameraMedium.at(snapshot, origin, tintProvider); return CameraMedium.at(snapshot, origin, tintProvider);
} }
public int trace(WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene) {
return trace(snapshot, origin, direction, sky, scene, cameraMedium(snapshot, origin));
}
/** As {@link #trace(WorldSnapshot, Vector, Vector, SkyContext, EntityScene)} with the camera medium given. */
public int trace( public int trace(
WorldSnapshot snapshot, WorldSnapshot snapshot,
Vector origin, Vector origin,
@@ -119,26 +106,25 @@ public final class SnapshotRaytracer {
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ(); double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ(); double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
int skyColor = skyRenderer.colorFor(direction, origin, sky);
BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium); BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth, medium);
boolean entities = scene != null && !scene.isEmpty(); boolean entities = scene != null && !scene.isEmpty();
// Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE, // Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE,
// ray t, already lit: fluid faces carry their own sky light). // ray t, already lit).
int[] layerColor = null; int[] layerColor = null;
double[] layerAlpha = null, layerT = null; double[] layerAlpha = null, layerT = null;
boolean[] layerLit = null; boolean[] layerLit = null;
int layers = 0; int layers = 0;
double transmittance = 1; double transmittance = 1;
int finalColor = skyColor; int finalColor = 0;
double finalT = Double.POSITIVE_INFINITY; // sky double finalT = Double.POSITIVE_INFINITY; // sky
boolean finalLit = false; boolean finalLit = false;
double minT = 0; double minT = 0;
BlockHit pending = null; // nearest block surface not consumed yet BlockHit pending = null; // nearest block surface not consumed yet
// Walk the surfaces along the ray in depth order (blocks, fluid faces and entities interleaved); // Surfaces in depth order (blocks, fluid faces and entities interleaved): translucent ones become layers,
// translucent ones are collected as layers and the ray continues behind them, the first opaque one ends it. // the first opaque one ends the ray.
while (true) { while (true) {
if (pending == null) pending = blocks.next(minT); if (pending == null) pending = blocks.next(minT);
double limit = pending != null ? pending.t : maxDistance; double limit = pending != null ? pending.t : maxDistance;
@@ -148,8 +134,7 @@ public final class SnapshotRaytracer {
boolean additive = false; boolean additive = false;
boolean lit = false; boolean lit = false;
if (eh != null) { if (eh != null) {
// Emissive additive surfaces (energy swirl) and layers without cardinal lighting (eyes, breeze // Vanilla NO_CARDINAL_LIGHTING: additive energy swirl, eyes, breeze wind.
// wind) are unshaded: vanilla NO_CARDINAL_LIGHTING (+ EMISSIVE).
additive = eh.additive(); additive = eh.additive();
boolean flat = additive || eh.unshaded(); boolean flat = additive || eh.unshaded();
color = flat ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal())); color = flat ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
@@ -165,8 +150,7 @@ public final class SnapshotRaytracer {
int alpha = ColorUtil.alpha(color); int alpha = ColorUtil.alpha(color);
double a = alpha / 255.0; double a = alpha / 255.0;
if (additive) { if (additive) {
// Additive light never hides what lies behind it: it neither ends the ray nor dims it. Beyond the // Additive light neither ends nor dims the ray; beyond the layer budget it is dropped.
// layer budget it is dropped.
if (layers == MAX_TRANSLUCENT_LAYERS) { if (layers == MAX_TRANSLUCENT_LAYERS) {
minT = t + LAYER_EPS; minT = t + LAYER_EPS;
continue; continue;
@@ -174,7 +158,6 @@ public final class SnapshotRaytracer {
} else if (alpha >= OPAQUE_ALPHA } else if (alpha >= OPAQUE_ALPHA
|| layers == MAX_TRANSLUCENT_LAYERS || layers == MAX_TRANSLUCENT_LAYERS
|| transmittance * (1 - a) < MIN_TRANSMITTANCE) { || transmittance * (1 - a) < MIN_TRANSMITTANCE) {
// Opaque, or what lies behind would contribute next to nothing: this surface ends the ray.
finalColor = color; finalColor = color;
finalT = t; finalT = t;
finalLit = lit; finalLit = lit;
@@ -195,51 +178,35 @@ public final class SnapshotRaytracer {
minT = t + LAYER_EPS; minT = t + LAYER_EPS;
} }
// Compose back to front: final surface, translucent layers (fluid faces among them), each fogged. // Compose back to front, each surface fogged; surfaces under water dimmed by their sky light. The sky
// Surfaces under water are dimmed by their depth like vanilla sky light. // colour is only computed when something reads it (most rays end on a near surface).
int skyColor = medium.needsSkyColor(finalT) ? skyRenderer.colorFor(direction, origin, sky) : 0;
int color; int color;
if (Double.isInfinite(finalT)) { if (Double.isInfinite(finalT)) {
color = medium.inFluid() ? medium.sky(finalColor, dx, dy, dz) : finalColor; color = medium.sky(skyColor, dx, dy, dz);
} else { } else {
int lit = finalLit ? finalColor : blocks.underwaterLight(finalColor, finalT); int lit = finalLit ? finalColor : blocks.underwaterLight(finalColor, finalT);
color = fog(lit, finalT, skyColor, medium); color = medium.fog(lit, finalT, skyColor);
} }
for (int i = layers - 1; i >= 0; i--) { for (int i = layers - 1; i >= 0; i--) {
if (layerAlpha[i] == ADDITIVE) { if (layerAlpha[i] == ADDITIVE) {
// Added light fades with distance like the surface it would otherwise be. double f = medium.fogValue(layerT[i]);
double f = medium.inFluid() ? medium.fogValue(layerT[i]) : fogFactor(layerT[i]);
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f)); color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f));
} else { } else {
int layer = layerLit[i] ? layerColor[i] : blocks.underwaterLight(layerColor[i], layerT[i]); int layer = layerLit[i] ? layerColor[i] : blocks.underwaterLight(layerColor[i], layerT[i]);
color = ColorUtil.mix(fog(layer, layerT[i], skyColor, medium), color, layerAlpha[i], 1 - layerAlpha[i]); color = ColorUtil.mix(medium.fog(layer, layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]);
} }
} }
return color & 0xFFFFFF; return color & 0xFFFFFF;
} }
/** Distance fog: the camera fluid's fog inside water/lava, the atmospheric one otherwise. */ /** A surface found by the {@link BlockCursor}; {@code lit}: carries its own sky light (fluid faces). */
private int fog(int color, double distance, int skyColor, CameraMedium medium) {
if (medium.inFluid()) return medium.fog(color, distance);
return fog(color, distance, skyColor);
}
private int fog(int color, double distance, int skyColor) {
double f = fogFactor(distance);
return f > 0 ? ColorUtil.mix(color, skyColor, 1 - f, f) : color;
}
/**
* A block surface found by the {@link BlockCursor}: shaded/tinted ARGB colour, ray parameter and whether it is
* already lit (fluid faces carry their own sky light).
*/
private record BlockHit(int color, double t, boolean lit) {} private record BlockHit(int color, double t, boolean lit) {}
/** /**
* Walks the voxel grid along one ray and hands out block surfaces in depth order. It stays on the voxel * Walks the voxel grid along one ray and hands out block surfaces in depth order. It stays on the voxel of the
* of the last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces * last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces further inside the
* further inside the same block (slime/honey block cores, the far side of a pane, the water polygons of a * same block (slime/honey cores, the far side of a pane, the water polygons of a waterlogged block).
* waterlogged block). Fluids are polygons like vanilla's FluidRenderer ({@link FluidRenderer}): every drawn
* water face the ray crosses is a translucent layer (texture alpha), lava faces are opaque.
*/ */
private final class BlockCursor { private final class BlockCursor {
private final WorldSnapshot snapshot; private final WorldSnapshot snapshot;
@@ -249,11 +216,13 @@ public final class SnapshotRaytracer {
private final int depth; private final int depth;
private final CameraMedium medium; private final CameraMedium medium;
private final double ox, oy, oz, dx, dy, dz; private final double ox, oy, oz, dx, dy, dz;
private final FluidRenderer.BlockLookup blocks;
private final VoxelDDA dda; private final VoxelDDA dda;
private int steps; private int steps;
private boolean done; private boolean done;
// The ray met water (a water cell, or the camera is in water): surfaces may need the underwater light. // The ray met water (a water cell, or the camera is in water): surfaces may need the underwater light.
// Checked when composing, after the whole walk.
private boolean sawWater; private boolean sawWater;
private boolean reflected; private boolean reflected;
@@ -277,6 +246,7 @@ public final class SnapshotRaytracer {
this.dx = direction.getX(); this.dx = direction.getX();
this.dy = direction.getY(); this.dy = direction.getY();
this.dz = direction.getZ(); this.dz = direction.getZ();
this.blocks = snapshot::getBlockData;
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz); this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
this.sawWater = medium.fluid() == FluidRenderer.WATER; this.sawWater = medium.fluid() == FluidRenderer.WATER;
} }
@@ -300,21 +270,22 @@ public final class SnapshotRaytracer {
ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT); ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT);
int fluidState = FluidRenderer.state(data); int fluidState = FluidRenderer.state(data);
if (fluidState != 0) { if (fluidState != 0) {
if (FluidRenderer.type(fluidState) == FluidRenderer.WATER) sawWater = true; int type = FluidRenderer.type(fluidState);
FluidRenderer.Crossing c = fluids.nearest( if (type == FluidRenderer.WATER) sawWater = true;
snapshot::getBlockData, bx, by, bz, data, fluidState, ox, oy, oz, dx, dy, dz, minT); FluidRenderer.Crossing c =
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, bx, by, bz); fluids.nearest(blocks, bx, by, bz, model, fluidState, ox, oy, oz, dx, dy, dz, minT);
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, type, bx, by, bz);
} }
if (hit == null) { if (hit == null) {
advance(); advance();
continue; continue;
} }
int color = shadeAndTint(hit, data, snapshot, bx, by, bz); // Shading and tint keep the texel alpha, so the culling test can run first.
if (ColorUtil.alpha(color) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) { if (ColorUtil.alpha(hit.color()) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) {
minT = hit.t(); // inner face between two blocks of the same translucent kind: not drawn minT = hit.t();
continue; continue;
} }
return new BlockHit(color, hit.t(), false); return new BlockHit(shadeAndTint(hit, data, snapshot, bx, by, bz), hit.t(), false);
} }
return null; return null;
} }
@@ -325,23 +296,23 @@ public final class SnapshotRaytracer {
} }
/** /**
* A crossed fluid polygon as a surface: texel × biome water colour (water) × face shade × sky light of the * A crossed fluid polygon as a surface: texel × biome water colour × face shade × sky light of the face's
* face's cell (max with the one above, like vanilla's light coords; water only, lava glows). The first * cell (the one below for the bottom face; max with the cell above, like vanilla's light coords). Lava is
* water surface entered from the air also reflects (folded into the layer colour/alpha). * neither tinted nor dimmed. The first water surface entered from the air also reflects.
*/ */
private BlockHit fluidHit(FluidRenderer.Crossing c, int bx, int by, int bz) { private BlockHit fluidHit(FluidRenderer.Crossing c, int type, int bx, int by, int bz) {
int color = c.texel; int color = c.texel;
boolean water = c.type == FluidRenderer.WATER; boolean water = type == FluidRenderer.WATER;
double light = 1; double light = 1;
if (water) { if (water) {
BiomeTint tint = blendedTint(snapshot, bx, by, bz); BiomeTint tint = blendedTint(snapshot, bx, by, bz);
if (tint != null) color = ColorUtil.multiply(color, tint.water()); if (tint != null) color = ColorUtil.multiply(color, tint.water());
int y = c.bottom ? by - 1 : by;
int level = Math.max( int level = Math.max(
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY, c.lightZ), FluidRenderer.skyLevel(blocks, bx, y, bz), FluidRenderer.skyLevel(blocks, bx, y + 1, bz));
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY + 1, c.lightZ));
if (level < 15) light = SKY_LIGHT[level]; if (level < 15) light = SKY_LIGHT[level];
} }
color = ColorUtil.shade(color, c.shade * light); color = ColorUtil.shade(color, shadeFactor(c.nx, c.ny, c.nz) * light);
if (water && c.entering && !reflected && depth > 0 && !medium.inFluid()) { if (water && c.entering && !reflected && depth > 0 && !medium.inFluid()) {
reflected = true; reflected = true;
double px = ox + dx * c.t, py = oy + dy * c.t, pz = oz + dz * c.t; double px = ox + dx * c.t, py = oy + dy * c.t, pz = oz + dz * c.t;
@@ -349,7 +320,7 @@ public final class SnapshotRaytracer {
Vector reflectDir = MathUtil.reflectVector(direction, normal); Vector reflectDir = MathUtil.reflectVector(direction, normal);
Vector reflectStart = new Vector(px, py, pz).add(normal.clone().multiply(1e-3)); Vector reflectStart = new Vector(px, py, pz).add(normal.clone().multiply(1e-3));
int reflection = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1, CameraMedium.AIR); int reflection = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1, CameraMedium.AIR);
// mix(layer over behind, reflection): a layer with alpha a' = 1 - (1-a)(1-r). // mix(layer over behind, reflection) as one layer with alpha a' = 1 - (1-a)(1-r).
double a = ColorUtil.alpha(color) / 255.0, r = WATER_REFLECTION; double a = ColorUtil.alpha(color) / 255.0, r = WATER_REFLECTION;
double a2 = 1 - (1 - a) * (1 - r); double a2 = 1 - (1 - a) * (1 - r);
double wl = a * (1 - r) / a2, wr = r / a2; double wl = a * (1 - r) / a2, wr = r / a2;
@@ -364,25 +335,22 @@ public final class SnapshotRaytracer {
} }
/** /**
* {@code color} of a surface at ray parameter {@code t} dimmed like vanilla sky light under water: by the * {@code color} of a surface at ray parameter {@code t} dimmed by the sky light of the block in front of it
* light level of the block in front of the surface (see {@link FluidRenderer#skyLevel}). Unchanged when the * (back along the ray), when the ray met water.
* ray never met water.
*/ */
int underwaterLight(int color, double t) { int underwaterLight(int color, double t) {
if (!sawWater || Double.isInfinite(t)) return color; if (!sawWater) 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); double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
int bx = (int) Math.floor(ox + dx * (t - back)); int bx = (int) Math.floor(ox + dx * (t - back));
int by = (int) Math.floor(oy + dy * (t - back)); int by = (int) Math.floor(oy + dy * (t - back));
int bz = (int) Math.floor(oz + dz * (t - back)); int bz = (int) Math.floor(oz + dz * (t - back));
int level = FluidRenderer.skyLevel(snapshot::getBlockData, bx, by, bz); int level = FluidRenderer.skyLevel(blocks, bx, by, bz);
return level >= 15 ? color : ColorUtil.shade(color, SKY_LIGHT[level]); return level >= 15 ? color : ColorUtil.shade(color, SKY_LIGHT[level]);
} }
/** /**
* Vanilla skips the faces between two translucent blocks of the same kind (stained glass walls, ice, * Vanilla skips the faces between two translucent blocks of the same kind (HalfTransparentBlock: stained
* connected panes): true when the hit lies on the block boundary and the neighbour across it is the * glass walls, ice, connected panes): the hit lies on the block boundary and the neighbour is the same type.
* same block type.
*/ */
private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) { private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) {
Vector n = hit.normal(); Vector n = hit.normal();
@@ -420,93 +388,64 @@ public final class SnapshotRaytracer {
return ColorUtil.shade(color, light); return ColorUtil.shade(color, light);
} }
private double fogFactor(double distance) {
if (distance <= FOG_START) return 0;
double f = (distance - FOG_START) / (FOG_END - FOG_START);
return Math.clamp(f, 0, FOG_MAX);
}
/** /**
* Vanilla-style smooth ambient occlusion: darkens face corners by how many of the three blocks * Smooth ambient occlusion like vanilla: each face corner is darkened by how many of the three blocks touching
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated * it (in the layer just outside the face) are solid, bilinearly interpolated across the face. Axis-aligned
* across the face. Only applied to axis-aligned faces. * faces only.
*/ */
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) { private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
double nx = hit.normal().getX(), double nx = hit.normal().getX(),
ny = hit.normal().getY(), ny = hit.normal().getY(),
nz = hit.normal().getZ(); nz = hit.normal().getZ();
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz); double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0; // skip rotated/diagonal faces if (Math.max(ax, Math.max(ay, az)) < 0.99) return 1.0;
double lx = hit.point().getX() - bx; double lx = hit.point().getX() - bx;
double ly = hit.point().getY() - by; double ly = hit.point().getY() - by;
double lz = hit.point().getZ() - bz; double lz = hit.point().getZ() - bz;
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords. // In-plane axes u = (ux, 0, uz), v = (0, vy, vz) and the face coordinates along them.
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz); int ux, uz, vy, vz;
int ux, uy, uz, vx, vy, vz;
double su, sv; double su, sv;
if (ay > 0.5) { // up/down if (ay > 0.5) { // up/down
ux = 1; ux = 1;
uy = 0;
uz = 0; uz = 0;
vx = 0;
vy = 0; vy = 0;
vz = 1; vz = 1;
su = lx; su = lx;
sv = lz; sv = lz;
} else if (ax > 0.5) { // east/west } else if (ax > 0.5) { // east/west
ux = 0; ux = 0;
uy = 0;
uz = 1; uz = 1;
vx = 0;
vy = 1; vy = 1;
vz = 0; vz = 0;
su = lz; su = lz;
sv = ly; sv = ly;
} else { // north/south } else { // north/south
ux = 1; ux = 1;
uy = 0;
uz = 0; uz = 0;
vx = 0;
vy = 1; vy = 1;
vz = 0; vz = 0;
su = lx; su = lx;
sv = ly; sv = ly;
} }
// The cell just outside the face.
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1); int x = bx + (int) Math.round(nx), y = by + (int) Math.round(ny), z = bz + (int) Math.round(nz);
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1); boolean uMinus = solid(snapshot, x - ux, y, z - uz), uPlus = solid(snapshot, x + ux, y, z + uz);
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1); boolean vMinus = solid(snapshot, x, y - vy, z - vz), vPlus = solid(snapshot, x, y + vy, z + vz);
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1); double b00 = aoBrightness(uMinus, vMinus, solid(snapshot, x - ux, y - vy, z - uz - vz));
double b10 = aoBrightness(uPlus, vMinus, solid(snapshot, x + ux, y - vy, z + uz - vz));
double b01 = aoBrightness(uMinus, vPlus, solid(snapshot, x - ux, y + vy, z - uz + vz));
double b11 = aoBrightness(uPlus, vPlus, solid(snapshot, x + ux, y + vy, z + uz + vz));
double top = b00 + (b10 - b00) * su; double top = b00 + (b10 - b00) * su;
double bottom = b01 + (b11 - b01) * su; double bottom = b01 + (b11 - b01) * su;
return top + (bottom - top) * sv; return top + (bottom - top) * sv;
} }
private double aoCorner( private static double aoBrightness(boolean side1, boolean side2, boolean corner) {
WorldSnapshot snapshot,
int bx,
int by,
int bz,
int ofx,
int ofy,
int ofz,
int ux,
int uy,
int uz,
int vx,
int vy,
int vz,
int du,
int dv) {
boolean side1 = solid(snapshot, bx + ofx + du * ux, by + ofy + du * uy, bz + ofz + du * uz);
boolean side2 = solid(snapshot, bx + ofx + dv * vx, by + ofy + dv * vy, bz + ofz + dv * vz);
boolean corner = solid(
snapshot, bx + ofx + du * ux + dv * vx, by + ofy + du * uy + dv * vy, bz + ofz + du * uz + dv * vz);
int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0); int level = (side1 && side2) ? 0 : 3 - (side1 ? 1 : 0) - (side2 ? 1 : 0) - (corner ? 1 : 0);
return AO_BRIGHTNESS[Math.clamp(level, 0, 3)]; return AO_BRIGHTNESS[level];
} }
private boolean solid(WorldSnapshot snapshot, int x, int y, int z) { private boolean solid(WorldSnapshot snapshot, int x, int y, int z) {
@@ -514,11 +453,7 @@ public final class SnapshotRaytracer {
return m != Material.AIR && m.isOccluding(); return m != Material.AIR && m.isOccluding();
} }
/** /** Biome tint averaged over the (2r+1)² columns around the block (vanilla biome blend, radius 2); cached per column. */
* Biome-blended tint: averages the per-biome tint over a {@code (2r+1)x(2r+1)} neighbourhood in
* X/Z (vanilla biome blend radius, default 2), giving smooth grass/foliage gradients across biome
* borders instead of hard edges. Cached per column.
*/
private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) { private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) {
long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32); long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32);
BiomeTint cached = tintCache.get(key); BiomeTint cached = tintCache.get(key);
@@ -545,21 +480,23 @@ public final class SnapshotRaytracer {
} }
private static void accumulate(long[] acc, int argb) { private static void accumulate(long[] acc, int argb) {
acc[0] += (argb >> 16) & 0xFF; acc[0] += ColorUtil.red(argb);
acc[1] += (argb >> 8) & 0xFF; acc[1] += ColorUtil.green(argb);
acc[2] += argb & 0xFF; acc[2] += ColorUtil.blue(argb);
} }
private static int average(long[] acc, int n) { private static int average(long[] acc, int n) {
return 0xFF000000 | (((int) (acc[0] / n)) << 16) | (((int) (acc[1] / n)) << 8) | ((int) (acc[2] / n)); return ColorUtil.argb(0xFF, (int) (acc[0] / n), (int) (acc[1] / n), (int) (acc[2] / n));
} }
/** Vanilla-style directional shading: top 1.0, north/south 0.8, east/west 0.6, bottom 0.5. */ private static double shadeFactor(Vector normal) {
private double shadeFactor(Vector normal) { return shadeFactor(normal.getX(), normal.getY(), normal.getZ());
double ax = Math.abs(normal.getX()); }
double ay = Math.abs(normal.getY());
double az = Math.abs(normal.getZ()); /** Vanilla directional shading by the dominant normal axis: up 1.0, down 0.5, north/south 0.8, east/west 0.6. */
if (ay >= ax && ay >= az) return normal.getY() >= 0 ? 1.0 : 0.5; private static double shadeFactor(double nx, double ny, double nz) {
double ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
if (ay >= ax && ay >= az) return ny >= 0 ? 1.0 : 0.5;
if (az >= ax) return 0.8; if (az >= ax) return 0.8;
return 0.6; return 0.6;
} }
@@ -1,23 +1,19 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace; package eu.mhsl.minecraft.pixelpics.render.raytrace;
/** /** Amanatides-Woo voxel traversal: the integer block cells a ray passes through, in order. */
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order, final class VoxelDDA {
* without any Bukkit world access (safe to run off the main thread).
*/
public final class VoxelDDA {
public int x, y, z; int x, y, z;
public double tCurrent; // ray parameter at which the current voxel was entered double tCurrent; // ray parameter at which the current voxel was entered
private final int stepX, stepY, stepZ; private final int stepX, stepY, stepZ;
private final double tDeltaX, tDeltaY, tDeltaZ; private final double tDeltaX, tDeltaY, tDeltaZ;
private double tMaxX, tMaxY, tMaxZ; private double tMaxX, tMaxY, tMaxZ;
public VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) { VoxelDDA(double ox, double oy, double oz, double dx, double dy, double dz) {
this.x = (int) Math.floor(ox); this.x = (int) Math.floor(ox);
this.y = (int) Math.floor(oy); this.y = (int) Math.floor(oy);
this.z = (int) Math.floor(oz); this.z = (int) Math.floor(oz);
this.tCurrent = 0;
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0); this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0); this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
@@ -38,8 +34,7 @@ public final class VoxelDDA {
return (next - origin) / dir; return (next - origin) / dir;
} }
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */ void advance() {
public void advance() {
if (tMaxX < tMaxY) { if (tMaxX < tMaxY) {
if (tMaxX < tMaxZ) { if (tMaxX < tMaxZ) {
x += stepX; x += stepX;
@@ -45,12 +45,9 @@ import org.bukkit.util.Vector;
* Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then * Renders the scene by capturing a world snapshot on the main thread ({@link #prepare}) and then
* tracing one ray per pixel in parallel against that snapshot ({@link #execute}). * tracing one ray per pixel in parallel against that snapshot ({@link #execute}).
*/ */
public class DefaultScreenRenderer implements Renderer { public class DefaultScreenRenderer {
/** /** Vertical field of view in degrees (vanilla's default FOV option is vertical too). */
* Vertical field of view in degrees (vanilla's default FOV option, which is vertical too); the
* horizontal FOV follows from the image aspect ratio. Square photos: 70° both ways.
*/
public static final double V_FOV_DEG = 70; public static final double V_FOV_DEG = 70;
// Camera.getFov in water/lava: fov · lerp(fovEffectScale = 1, 1, 6/7). // Camera.getFov in water/lava: fov · lerp(fovEffectScale = 1, 1, 6/7).
private static final double FLUID_FOV_SCALE = 6.0 / 7.0; private static final double FLUID_FOV_SCALE = 6.0 / 7.0;
@@ -58,7 +55,7 @@ public class DefaultScreenRenderer implements Renderer {
private static final double MAX_DISTANCE = 256; private static final double MAX_DISTANCE = 256;
private static final int REFLECTION_DEPTH = 4; private static final int REFLECTION_DEPTH = 4;
/** Supersampling factor: SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */ /** SSAA x SSAA rays per output pixel, downsampled gamma-correctly. */
private static final int SSAA = 3; private static final int SSAA = 3;
private final SnapshotRaytracer raytracer; private final SnapshotRaytracer raytracer;
@@ -109,22 +106,14 @@ public class DefaultScreenRenderer implements Renderer {
this.tracePool = tracePool; this.tracePool = tracePool;
} }
/** Convenience: prepare and execute in one call (must run on the main thread). */
@Override
public BufferedImage render(Location eyeLocation, Resolution resolution) {
return execute(prepare(eyeLocation, resolution, null));
}
/** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */ /** Builds the (supersampled) ray map and captures world + entities. MUST run on the main thread. */
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) { public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
int superW = resolution.getWidth() * SSAA; int superW = resolution.width() * SSAA;
int superH = resolution.getHeight() * SSAA; int superH = resolution.height() * SSAA;
// Vanilla Camera.getFov: in water or lava the FOV shrinks to 6/7 (FOV effects slider at its default 100%). World world = eyeLocation.getWorld();
World eyeWorld = eyeLocation.getWorld(); boolean inFluid = FluidRenderer.cameraFluid(
boolean inFluid = eyeWorld != null world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
&& FluidRenderer.cameraFluid( != FluidRenderer.NONE;
eyeWorld::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
!= FluidRenderer.NONE;
List<Vector> rayMap = List<Vector> rayMap =
buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG); buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger); WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger);
@@ -134,7 +123,6 @@ public class DefaultScreenRenderer implements Renderer {
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby); List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation); List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
World world = eyeLocation.getWorld();
long dayTime = world.getTime(); long dayTime = world.getTime();
long fullTime = world.getFullTime(); long fullTime = world.getFullTime();
int moonPhase = (int) (fullTime / 24000L % 8L); int moonPhase = (int) (fullTime / 24000L % 8L);
@@ -144,8 +132,8 @@ public class DefaultScreenRenderer implements Renderer {
snapshot, snapshot,
rayMap, rayMap,
eyeLocation.toVector(), eyeLocation.toVector(),
resolution.getWidth(), resolution.width(),
resolution.getHeight(), resolution.height(),
sky, sky,
entities, entities,
blockEntities, blockEntities,
@@ -159,9 +147,8 @@ public class DefaultScreenRenderer implements Renderer {
} }
/** /**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the * As {@link #execute(RenderJob)}, but abortable: once {@code cancelled} is set the remaining work is skipped and
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains * the returned image is partial (to be discarded).
* out quickly. The returned image is then partial and should be discarded by the caller.
*/ */
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) { public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
int finalW = job.width(); int finalW = job.width();
@@ -207,9 +194,8 @@ public class DefaultScreenRenderer implements Renderer {
} }
/** /**
* Runs a parallel-stream task. With a {@link #tracePool} the work is confined to that bounded, * Runs a parallel-stream task in the {@link #tracePool} (parallel streams adopt the pool of the running fork-join
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one * worker), or on the common pool without one (offline render tools).
* it runs inline on the common pool (used by the offline render tools).
*/ */
private void runParallel(Runnable task) { private void runParallel(Runnable task) {
if (tracePool == null) { if (tracePool == null) {
@@ -220,12 +206,9 @@ public class DefaultScreenRenderer implements Renderer {
} }
/** /**
* Pinhole camera like vanilla's projection: the rays go through a flat image plane one unit in front * Pinhole camera like vanilla's projection: {@code ray = forward + u·tan(fovX/2)·right + v·tan(fovY/2)·up} with
* of the eye, {@code ray = forward + u·tan(fovX/2)·right + v·tan(fovY/2)·up} with u, v ∈ [-1, 1] at * u, v ∈ [-1, 1] at the pixel centres, row 0 = top; {@code tan(fovX/2) = tan(fovY/2)·w/h}. Right/up come from
* the pixel centres, row 0 = top. The vertical FOV is fixed ({@link #V_FOV_DEG}, like vanilla's * the yaw of {@code eye} (no roll), so looking straight up or down keeps the yaw orientation.
* default 70°), the horizontal one follows from the aspect ratio ({@code tan(fovX/2) = tan(fovY/2)·w/h}),
* so one pixel covers the same angle (at the centre) both ways and squares stay square. Right/up come
* from the yaw/pitch of {@code eye} (no roll), so looking straight up or down keeps the yaw orientation.
*/ */
public static List<Vector> buildRayMap(Location eye, int width, int height) { public static List<Vector> buildRayMap(Location eye, int width, int height) {
return buildRayMap(eye, width, height, V_FOV_DEG); return buildRayMap(eye, width, height, V_FOV_DEG);
@@ -10,9 +10,8 @@ import java.util.List;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin, * A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin, sky state,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration * entity/block-entity/decoration/object states). {@link DefaultScreenRenderer#execute} runs it off the main thread.
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
*/ */
public record RenderJob( public record RenderJob(
WorldSnapshot snapshot, WorldSnapshot snapshot,
@@ -24,44 +23,4 @@ public record RenderJob(
List<EntityState> entities, List<EntityState> entities,
List<BlockEntityState> blockEntities, List<BlockEntityState> blockEntities,
List<DecorationState> decorations, List<DecorationState> decorations,
List<ObjectState> objects) { List<ObjectState> objects) {}
/** Without display/item/falling-block objects. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, decorations, List.of());
}
/** Backwards-compatible constructor (no block-entities/decorations), used by the standalone harness. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities) {
this(snapshot, rayMap, origin, width, height, sky, entities, List.of(), List.of());
}
/** Convenience for callers that supply entities + block-entities but no decorations. */
public RenderJob(
WorldSnapshot snapshot,
List<Vector> rayMap,
Vector origin,
int width,
int height,
SkyContext sky,
List<EntityState> entities,
List<BlockEntityState> blockEntities) {
this(snapshot, rayMap, origin, width, height, sky, entities, blockEntities, List.of());
}
}
@@ -1,8 +0,0 @@
package eu.mhsl.minecraft.pixelpics.render.render;
import java.awt.image.BufferedImage;
import org.bukkit.Location;
public interface Renderer {
BufferedImage render(Location eyeLocation, Resolution resolution);
}
@@ -2,54 +2,11 @@ package eu.mhsl.minecraft.pixelpics.render.render;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
public final class Resolution { /** Output size of a photo in pixels. */
public record Resolution(int width, int height) {
private final int width; public Resolution {
private final int height;
public Resolution(Pixels pixels, AspectRatio aspectRatio) {
this(
(int) Math.round(
Preconditions.checkNotNull(pixels).height * Preconditions.checkNotNull(aspectRatio).ratio),
pixels.height);
}
public Resolution(int width, int height) {
Preconditions.checkArgument(width > 0); Preconditions.checkArgument(width > 0);
Preconditions.checkArgument(height > 0); Preconditions.checkArgument(height > 0);
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public enum Pixels {
_128P(128),
_256P(256);
private final int height;
Pixels(int height) {
this.height = height;
}
}
public enum AspectRatio {
_1_1(1),
_2_1(2),
_3_2(3 / 2.0);
private final double ratio;
AspectRatio(double ratio) {
this.ratio = ratio;
}
} }
} }
@@ -6,9 +6,8 @@ import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient * Sky colour of rays that escape the world (not vanilla's sky renderer): day/night gradient with twilight glow, sun
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer. * and moon (with phase), stars and vanilla's cloud layer. Immutable, so thread safe.
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe.
*/ */
public final class SkyRenderer { public final class SkyRenderer {
@@ -17,8 +16,9 @@ public final class SkyRenderer {
private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel private static final double CLOUD_CELL = 12.0; // world blocks per cloud texel
private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X private static final double CLOUD_SPEED = 0.03; // blocks per tick, drift along +X
private static final double SUN_HALF = 0.085; // angular half-size (radians) // sin of the angular half-size.
private static final double MOON_HALF = 0.075; private static final double SUN_SIN_HALF = Math.sin(0.085);
private static final double MOON_SIN_HALF = Math.sin(0.075);
// Gradient endpoints (RGB). // Gradient endpoints (RGB).
private static final int DAY_ZENITH = rgb(86, 138, 252); private static final int DAY_ZENITH = rgb(86, 138, 252);
@@ -29,13 +29,10 @@ public final class SkyRenderer {
private static final int SUNSET_RED = rgb(205, 70, 60); private static final int SUNSET_RED = rgb(205, 70, 60);
private static final int TWI_PURPLE = rgb(80, 42, 92); private static final int TWI_PURPLE = rgb(80, 42, 92);
private final int[][] sunTexture;
private final int[][] moonTexture; private final int[][] moonTexture;
private final int[][] cloudTexture; private final int[][] cloudTexture;
public SkyRenderer(TextureCache textures) { public SkyRenderer(TextureCache textures) {
this.sunTexture =
textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture = this.moonTexture =
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null); textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture = this.cloudTexture =
@@ -50,9 +47,8 @@ public final class SkyRenderer {
dy /= len; dy /= len;
dz /= len; dz /= len;
// Sun/moon position, derived exactly from Minecraft's sky transforms: // Vanilla sun direction: rotated by celestialAngle·360° about X (after -90° about Y)
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis // = (-sin(2π·ca), cos(2π·ca), 0).
// (after a -90deg Y rotation), giving sunDir = (-sin(2*pi*ca), cos(2*pi*ca), 0) in world space.
double ca = celestialAngle(ctx.dayTime()); double ca = celestialAngle(ctx.dayTime());
double ang = ca * 2 * Math.PI; double ang = ca * 2 * Math.PI;
double sunX = -Math.sin(ang), sunY = Math.cos(ang); double sunX = -Math.sin(ang), sunY = Math.cos(ang);
@@ -64,8 +60,8 @@ public final class SkyRenderer {
int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up); int nightColor = lerp(NIGHT_HORIZON, NIGHT_ZENITH, up);
int color = lerp(nightColor, dayColor, dayFactor); int color = lerp(nightColor, dayColor, dayFactor);
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith), // Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith), strongest while
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla. // the sun is near the horizon and warmer toward its azimuth.
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1); double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
if (twilight > 0) { if (twilight > 0) {
double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away double az = Math.clamp(dx * Math.signum(sunX) * 0.5 + 0.5, 0, 1); // 1 toward sun .. 0 away
@@ -83,7 +79,7 @@ public final class SkyRenderer {
double star = starField(dx, dy, dz); double star = starField(dx, dy, dz);
if (star > 0) { if (star > 0) {
int s = (int) (star * 255 * visibility); int s = (int) (star * 255 * visibility);
color = add(color, s, s, s); color = ColorUtil.add(color, rgb(s, s, s));
} }
} }
} }
@@ -97,19 +93,14 @@ public final class SkyRenderer {
} }
} }
// Sun disc (soft glowing disc, texture used only as a shape mask).
if (sunY > -0.15) { if (sunY > -0.15) {
color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_HALF, sunTexture, rgb(255, 244, 214), -1); color = overlayDisc(color, dx, dy, dz, sunX, sunY, 0, SUN_SIN_HALF, rgb(255, 244, 214), -1);
} }
// Moon disc (phase shape from the texture's alpha). if (sunY < 0.15) {
if (-sunY > -0.15) { color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_SIN_HALF, rgb(228, 228, 238), ctx.moonPhase());
color = overlayDisc(
color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
} }
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is // Cloud layer at y = CLOUD_HEIGHT, drifting along +X with the world time.
// mapped to a clouds.png texel exactly as vanilla does (see clouds()). Horizontal drift uses
// the world time (fullTime * CLOUD_SPEED along +X).
if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) { if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
double t = (CLOUD_HEIGHT - origin.getY()) / dy; double t = (CLOUD_HEIGHT - origin.getY()) / dy;
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED; double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
@@ -126,7 +117,11 @@ public final class SkyRenderer {
return color & 0xFFFFFF; return color & 0xFFFFFF;
} }
/** Draws a sun/moon disc, sampling a texture when available (moonPhase &ge; 0 picks the phase tile). */ /**
* Draws the sun or moon (a square billboard like vanilla) in colour {@code solid} around the body direction
* {@code c}. The sun ({@code moonPhase < 0}) is a solid square, the moon takes its phase shape from the alpha of
* the moon texture's tile (only a mask, so its dark transparent texels never bleed in as a rim).
*/
private int overlayDisc( private int overlayDisc(
int base, int base,
double dx, double dx,
@@ -135,15 +130,12 @@ public final class SkyRenderer {
double cx, double cx,
double cy, double cy,
double cz, double cz,
double half, double sinHalf,
int[][] texture,
int solid, int solid,
int moonPhase) { int moonPhase) {
double cos = dx * cx + dy * cy + dz * cz; double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base; if (cos <= 0) return base;
double sinHalf = Math.sin(half); // Disc coordinates: right = normalize(body x worldUp), discUp = right x body.
// Local disc coordinates: project the direction onto the plane around the body axis.
// right = normalize(body x worldUp); discUp = right x body
double crx = cz, cry = 0, crz = -cx; double crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz); double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) { if (crl < 1e-6) {
@@ -155,25 +147,20 @@ public final class SkyRenderer {
crx /= crl; crx /= crl;
cry /= crl; cry /= crl;
crz /= crl; crz /= crl;
// discUp = right cross body
double ux = cry * cz - crz * cy; double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz; double uy = crz * cx - crx * cz;
double uz = crx * cy - cry * cx; double uz = crx * cy - cry * cx;
double u = dx * crx + dy * cry + dz * crz; double u = dx * crx + dy * cry + dz * crz;
double v = dx * ux + dy * uy + dz * uz; double v = dx * ux + dy * uy + dz * uz;
// The sun and moon are flat SQUARE billboards in Minecraft, not round discs.
double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge double m = Math.max(Math.abs(u), Math.abs(v)) / sinHalf; // 0 center .. 1 square edge
if (m > 1) return base; if (m > 1) return base;
double su = u / sinHalf * 0.5 + 0.5; double su = u / sinHalf * 0.5 + 0.5;
double sv = v / sinHalf * 0.5 + 0.5; double sv = v / sinHalf * 0.5 + 0.5;
// The texture is used only as a shape/phase mask; the body color is always `solid` so the
// texture's black transparent texels never bleed in as a dark rim.
double alpha; double alpha;
if (moonPhase >= 0 && texture != null && texture.length > 0) { if (moonPhase >= 0 && moonTexture != null && moonTexture.length > 0) {
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape alpha = bodyAlpha(moonTexture, su, sv, moonPhase) > 80 ? 1.0 : 0.0;
} else { } else {
alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening alpha = 1 - smoothstep(0.92, 1.0, m); // solid square, faint edge softening
} }
@@ -181,17 +168,17 @@ public final class SkyRenderer {
return lerp(base, solid, alpha); return lerp(base, solid, alpha);
} }
/** Alpha of the body texture at the disc coordinate; moonPhase&ge;0 selects a tile in the 4x2 grid. */ /** Alpha of the texture at the disc coordinate in the moon phase's tile of the 4x2 grid. */
private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) { private int bodyAlpha(int[][] texture, double su, double sv, int moonPhase) {
int h = texture.length; int h = texture.length;
int w = texture[0].length; int w = texture[0].length;
double u = su, v = 1 - sv; // texture v is top-down double u = su, v = 1 - sv; // texture v is top-down
int col = moonPhase % 4; int col = moonPhase % 4;
int row = (moonPhase / 4) % 2; int row = moonPhase / 4;
u = (col + u) / 4.0; u = (col + u) / 4.0;
v = (row + v) / 2.0; v = (row + v) / 2.0;
int px = clamp((int) (u * w), 0, w - 1); int px = Math.clamp((int) (u * w), 0, w - 1);
int py = clamp((int) (v * h), 0, h - 1); int py = Math.clamp((int) (v * h), 0, h - 1);
return ColorUtil.alpha(texture[py][px]); return ColorUtil.alpha(texture[py][px]);
} }
@@ -206,13 +193,8 @@ public final class SkyRenderer {
} }
/** /**
* Exact vanilla cloud coverage. Minecraft tiles {@code clouds.png} (256x256) over the world with * Vanilla cloud coverage: {@code clouds.png} tiled over the world, one texel per {@link #CLOUD_CELL} block square
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every * (U along X, V along Z); a texel with alpha is a cloud. Value noise only when the pack lacks the texture.
* 256*12 = 3072 blocks. A world position maps to texel
* {@code (col = floorMod(floor(x/12), 256), row = floorMod(floor(z/12), 256))} with the texture's
* U axis along world X and V axis along world Z; a texel is a cloud where its alpha &gt; 0. This
* reproduces the blocky cloud shapes and their world alignment exactly. Falls back to value noise
* only when the texture is missing from the pack.
*/ */
private double clouds(double x, double z) { private double clouds(double x, double z) {
if (cloudTexture != null && cloudTexture.length > 0) { if (cloudTexture != null && cloudTexture.length > 0) {
@@ -250,8 +232,6 @@ public final class SkyRenderer {
return h ^ (h >>> 16); return h ^ (h >>> 16);
} }
// --- small color/math helpers ---
private static int rgb(int r, int g, int b) { private static int rgb(int r, int g, int b) {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
@@ -261,13 +241,6 @@ public final class SkyRenderer {
return ColorUtil.mix(a, b, 1 - t, t); return ColorUtil.mix(a, b, 1 - t, t);
} }
private static int add(int c, int r, int g, int b) {
int cr = Math.min(255, ((c >> 16) & 0xFF) + r);
int cg = Math.min(255, ((c >> 8) & 0xFF) + g);
int cb = Math.min(255, (c & 0xFF) + b);
return rgb(cr, cg, cb);
}
/** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */ /** Minecraft's {@code Level.getTimeOfDay}: the celestial angle as a fraction [0,1). */
private static double celestialAngle(long dayTime) { private static double celestialAngle(long dayTime) {
double d = frac(dayTime / TICKS_PER_DAY - 0.25); double d = frac(dayTime / TICKS_PER_DAY - 0.25);
@@ -283,8 +256,4 @@ public final class SkyRenderer {
double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1); double t = Math.clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t); return t * t * (3 - 2 * t);
} }
private static int clamp(int v, int lo, int hi) {
return v < lo ? lo : Math.min(v, hi);
}
} }
@@ -66,7 +66,7 @@ public final class PhotoService {
boolean dispatched = false; boolean dispatched = false;
try { try {
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1); Resolution resolution = new Resolution(128, 128);
// Capture the world snapshot on the main thread (the moment of the shot). // Capture the world snapshot on the main thread (the moment of the shot).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user); RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
+2 -2
View File
@@ -377,7 +377,7 @@ public class BlockEntityTestRender {
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ()); Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(center.clone().subtract(cam)); loc.setDirection(center.clone().subtract(cam));
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA); List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), states); RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), states, List.of(), List.of());
return renderer.execute(job); return renderer.execute(job);
} }
@@ -403,7 +403,7 @@ public class BlockEntityTestRender {
Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ()); Location loc = new Location(null, cam.getX(), cam.getY(), cam.getZ());
loc.setDirection(center.clone().subtract(cam)); loc.setDirection(center.clone().subtract(cam));
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA); List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), List.of(), states); RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(), List.of(), states, List.of());
return renderer.execute(job); return renderer.execute(job);
} }
+1 -1
View File
@@ -250,7 +250,7 @@ public class EntityTestRender {
loc.setDirection(center.clone().subtract(cam)); loc.setDirection(center.clone().subtract(cam));
List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA); List<Vector> rayMap = buildRayMap(loc, TW * SSAA, TH * SSAA);
RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(s)); RenderJob job = new RenderJob(world, rayMap, cam, TW, TH, sky, List.of(s), List.of(), List.of(), List.of());
return renderer.execute(job); return renderer.execute(job);
} }