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;
/**
* The fluid the camera is in and its fog, like vanilla 26.3 {@code FogRenderer} with {@code WaterFogEnvironment} /
* {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd} (spherical distance from the
* camera) towards {@code fogColor}, applied to every surface; the sky is the fogged sky disc.
* What the camera looks through and its distance fog. In water/lava like vanilla 26.3 {@code FogRenderer} with
* {@code WaterFogEnvironment} / {@code LavaFogEnvironment}: linear fog from {@code fogStart} to {@code fogEnd}
* (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),
* {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), each blended
* over 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
* .getWaterVision}), which keeps the end distance and brightens the colour to a maximum channel of 1. Lava:
* #991A00 from 0.25 to 1 block (no fire resistance).
* {@code water_fog_end_distance} (96, swamps x0.85) and {@code water_fog_color} (default #050533), blended over
* the biomes around the camera with vanilla's {@code GaussianSampler} (6x6x6 quart cells, kernel 1-4-6-4-1).
* Water vision ({@code LocalPlayer.getWaterVision}) is taken as fully adapted (1), which keeps the end distance and
* brightens the colour to a maximum channel of 1. Lava: #991A00 from 0.25 to 1 block (no fire resistance).
*/
public record CameraMedium(int fluid, int fogColor, double fogStart, double fogEnd) {
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};
// 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.
private static final double DISC_HEIGHT = 16;
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_RIM_DISTANCE = Math.hypot(DISC_RADIUS, DISC_HEIGHT);
public boolean inFluid() {
return fluid != FluidRenderer.NONE;
}
/** The medium at {@code eye} in {@code snapshot}. */
public static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
static CameraMedium at(WorldSnapshot snapshot, Vector eye, BiomeTintProvider tints) {
double x = eye.getX(), y = eye.getY(), z = eye.getZ();
int fluid = FluidRenderer.cameraFluid(snapshot::getBlockData, x, y, z);
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);
BiomeClimate.Climate c = biome == null ? BiomeClimate.DEFAULT : tints.climate(biome);
int fog = c.waterFogColor();
r += w * ((fog >> 16) & 0xFF) / 255.0;
g += w * ((fog >> 8) & 0xFF) / 255.0;
b += w * (fog & 0xFF) / 255.0;
r += w * ColorUtil.red(fog) / 255.0;
g += w * ColorUtil.green(fog) / 255.0;
b += w * ColorUtil.blue(fog) / 255.0;
start += w * c.waterFogStart();
end += w * c.waterFogEnd();
total += w;
@@ -74,14 +78,13 @@ public record CameraMedium(int fluid, int fogColor, double fogStart, double fogE
b /= total;
start /= 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) {
double scale = 1 / Math.max(r, Math.max(g, b));
r = r + (r * scale - r) * WATER_VISION;
g = g + (g * scale - g) * WATER_VISION;
b = b + (b * scale - b) * WATER_VISION;
r = r + (r * scale - r);
g = g + (g * scale - g);
b = b + (b * scale - b);
}
end *= Math.max(0.25, WATER_VISION);
int color = ColorUtil.argb(255, channel(r), channel(g), channel(b)) & 0xFFFFFF;
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);
}
/** fog.glsl linear_fog_value at distance {@code d}. */
public double fogValue(double d) {
/** Fog fraction at distance {@code d} (in fluids fog.glsl {@code linear_fog_value}). */
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 >= fogEnd) return 1;
return (d - fogStart) / (fogEnd - fogStart);
}
/** {@code color} (RGB) fogged at distance {@code d}. */
public int fog(int color, double d) {
/** {@code color} fogged at distance {@code d}; air fogs towards {@code skyColor}. */
int fog(int color, double d, int skyColor) {
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
* fog over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles
* (centre 16, rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and
* beyond the disc only the clear colour (= fog colour) remains.
* The sky seen in direction {@code (dx, dy, dz)} (unit). From inside a fluid vanilla draws the sky disc with fog
* over {@code [0, skyEnd = fogEnd]} using the vertex distances interpolated across the fan triangles (centre 16,
* rim 512, plus the cylindrical distance beyond skyEnd = fully fogged); below the horizon and beyond the disc
* 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;
double horizontal = Math.sqrt(dx * dx + dz * dz);
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;
}
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 f = cylindrical > fogEnd ? 1 : Math.clamp(spherical / fogEnd, 0, 1);
return ColorUtil.mix(skyColor, fogColor, 1 - f, f);
@@ -11,11 +11,9 @@ import java.util.List;
import org.bukkit.util.Vector;
/**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
* the nearest drawn face hit. Axis-aligned boxes use a slab test; rotated elements are handled as
* oriented boxes by transforming the ray into the element's local frame. Fully transparent texels
* (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).
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns the nearest drawn
* face hit. Rotated elements are slab-tested in their local frame. Cut-out texels are holes the ray passes through;
* partially transparent texels are returned with their alpha for the caller to blend.
*/
public final class ElementIntersector {
@@ -24,10 +22,7 @@ public final class ElementIntersector {
private ElementIntersector() {}
/**
* 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).
*/
/** Per-thread scratch of one {@link #intersect} call: candidate faces as parallel arrays, so nothing is allocated. */
private static final class Scratch {
int n;
Element[] element = new Element[16];
@@ -44,8 +39,8 @@ public final class ElementIntersector {
double hitT, hitS, hitT2, hitNx, hitNy, hitNz;
Direction hitDir;
// Same ordering as before (depth; coplanar within 1e-4: later elements first) over candidate indices.
// Sorted with the same TimSort as the former List<Candidate>.sort, so ties resolve identically.
// Depth; coplanar within 1e-4: later elements first. Not transitive, so the tie order depends on the sort
// algorithm: keep Arrays.sort over objects (TimSort).
final Comparator<Integer> byDepth = (a, b) -> {
if (Math.abs(t[a] - t[b]) > 1e-4) return Double.compare(t[a], t[b]);
return Integer.compare(order[b], order[a]);
@@ -94,29 +89,29 @@ public final class ElementIntersector {
double[] v = sc.v;
for (int i = 0; i < count; i++) {
Element element = elements.get(i);
boolean hit;
Element e;
Element e = element;
double eox = ox, eoy = oy, eoz = oz, edx = dx, edy = dy, edz = dz;
if (element.unrotated != null) {
// 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;
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);
hit = e.isAxisAligned()
? slab(sc, mox, moy, moz, v[0], v[1], v[2], e.from, e.to, null)
: intersectObb(sc, e, mox, moy, moz, v[0], v[1], v[2]);
if (hit) {
rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
}
} else {
e = element;
hit = e.isAxisAligned()
? slab(sc, ox, oy, oz, dx, dy, dz, e.from, e.to, null)
: intersectObb(sc, e, ox, oy, oz, dx, dy, dz);
edx = v[0];
edy = v[1];
edz = v[2];
}
boolean hit = e.isAxisAligned()
? slab(sc, eox, eoy, eoz, edx, edy, edz, e.from, e.to, null)
: intersectObb(sc, e, eox, eoy, eoz, edx, edy, edz);
if (hit && element.unrotated != null) {
rotate(v, sc.hitNx, sc.hitNy, sc.hitNz, element.xSteps, element.ySteps, false);
sc.hitNx = v[0];
sc.hitNy = v[1];
sc.hitNz = v[2];
}
if (!hit || !(sc.hitT > minT)) continue;
int k = sc.n;
@@ -135,9 +130,8 @@ public final class ElementIntersector {
int n = sc.n;
if (n == 0) return null;
Integer[] idx = sc.idx;
for (int k = 0; k < n; k++) idx[k] = k; // autoboxing hits the Integer cache for k < 128
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's
// draw order so overlays (e.g. the tinted grass side overlay) sit on top of the base face.
for (int k = 0; k < n; k++) idx[k] = k; // Integer cache for k < 128
// Coplanar faces: later elements first, like vanilla's draw order (overlays such as the grass side tint).
if (n > 1) Arrays.sort(idx, 0, n, sc.byDepth);
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.
* Vanilla bakes {@code origin + R·S·(p - origin)} (S = rescale, see {@link Element#invScale}); the inverse is
* affine, so the ray parameter t stays the block-space one.
* Rotated element: slab test in the element's local (unrotated, unscaled) frame. Vanilla bakes
* {@code origin + R·S·(p - origin)} (S = rescale); the inverse is affine, so t stays the block-space one.
*/
private static boolean intersectObb(
Scratch sc, Element e, double ox, double oy, double oz, double dx, double dy, double dz) {
@@ -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(
Scratch sc,
double ox,
@@ -204,14 +200,6 @@ public final class ElementIntersector {
double[] from,
double[] to,
Element obb) {
if (from[0] > to[0] || from[1] > to[1] || from[2] > to[2]) {
return invertedBox(sc, ox, oy, oz, dx, dy, dz, from, to, obb);
}
double tmin = Double.NEGATIVE_INFINITY;
double tmax = Double.POSITIVE_INFINITY;
int axis = -1;
boolean negFace = false; // entered through the low-coordinate face
double[] o = sc.o, d = sc.d;
o[0] = ox;
o[1] = oy;
@@ -219,6 +207,12 @@ public final class ElementIntersector {
d[0] = dx;
d[1] = dy;
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++) {
if (Math.abs(d[a]) < EPS) {
if (o[a] < from[a] - EPS || o[a] > to[a] + EPS) return false;
@@ -247,7 +241,7 @@ public final class ElementIntersector {
double tEntry = tmin;
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;
if (tEntry < EPS) return false;
}
@@ -257,31 +251,10 @@ public final class ElementIntersector {
double pz = o[2] + d[2] * tEntry;
Direction dir = faceFor(axis, negFace);
if (obb != null) {
// rotate the normal back into block space
// (rescale is diagonal in the local frame, so an axis normal keeps its direction: n = R·n_local)
double[] v = sc.v;
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;
}
setNormal(sc, obb, dir.nx, dir.ny, dir.nz);
double fracX = frac(px, from[0], to[0]);
double fracY = frac(py, from[1], to[1]);
double fracZ = frac(pz, from[2], to[2]);
sc.hitT = tEntry;
sc.hitDir = dir;
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,
* and every inverted OTHER axis mirrors the quad, flipping its winding. So these faces point into the box and
* are seen from inside (through the cage: the far wall). Each face is tested as its own back-face-culled quad;
* the nearest visible one wins. Texture coordinates follow the mirrored corners (signed span).
* 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(
Scratch sc,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double[] from,
double[] to,
Element obb) {
private static boolean invertedBox(Scratch sc, double[] from, double[] to, Element obb) {
double[] o = sc.o, d = sc.d;
o[0] = ox;
o[1] = oy;
o[2] = oz;
d[0] = dx;
d[1] = dy;
d[2] = dz;
double best = Double.POSITIVE_INFINITY;
Direction bestDir = null;
double bestSign = 0;
@@ -335,36 +293,44 @@ public final class ElementIntersector {
}
if (bestDir == null) return false;
int a = bestDir.nx != 0 ? 0 : bestDir.ny != 0 ? 1 : 2;
double nx = a == 0 ? bestSign : 0, ny = a == 1 ? bestSign : 0, nz = a == 2 ? bestSign : 0;
if (obb != null) {
double[] v = sc.v;
double[] m = obb.rotMatrix;
if (m != null) {
v[0] = m[0] * nx + m[1] * ny + m[2] * nz;
v[1] = m[3] * nx + m[4] * ny + m[5] * nz;
v[2] = m[6] * nx + m[7] * ny + m[8] * nz;
} else {
rotate(v, nx, ny, nz, obb.rotAxis, obb.rotCos, obb.rotSin);
}
nx = v[0];
ny = v[1];
nz = v[2];
}
double fx = signedFrac(o[0] + d[0] * best, from[0], to[0]);
double fy = signedFrac(o[1] + d[1] * best, from[1], to[1]);
double fz = signedFrac(o[2] + d[2] * best, from[2], to[2]);
setNormal(sc, obb, a == 0 ? bestSign : 0, a == 1 ? bestSign : 0, a == 2 ? bestSign : 0);
double fx = frac(o[0] + d[0] * best, from[0], to[0]);
double fy = frac(o[1] + d[1] * best, from[1], to[1]);
double fz = frac(o[2] + d[2] * best, from[2], to[2]);
sc.hitT = best;
sc.hitDir = bestDir;
sc.hitNx = nx;
sc.hitNy = ny;
sc.hitNz = nz;
sc.hitS = faceS(bestDir, fx, fy, fz);
sc.hitT2 = faceT(bestDir, fy, fz);
return true;
}
/**
* 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. */
private static double signedFrac(double v, double from, double to) {
private static double frac(double v, double from, double to) {
double span = to - from;
if (Math.abs(span) < 1e-6) return 0;
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
* {@code BlockElement.uvsByFace}: a face's UV rect {@code [u1,v1,u2,v2]} is lerped with (s, t), so north/east
* faces run their horizontal axis towards -x/-z (left-to-right seen from outside) and down runs v towards
* north. Shared with the entity intersector (entity box-UV uses the same orientation).
* Face-local horizontal coordinate s in 0..1 for a hit at box fractions (fx, fy, fz), like vanilla's
* {@code BlockElement.uvsByFace}: north/east faces run towards -x/-z (left to right seen from outside), down runs
* v towards north. Shared with the entity intersector (box UVs have the same orientation).
*/
public static double faceS(Direction dir, double fx, double fy, double fz) {
return switch (dir) {
@@ -403,14 +368,7 @@ public final class ElementIntersector {
};
}
private static double frac(double v, double lo, double hi) {
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. */
/** Rotates (x, y, z) about axis 0/1/2 (x/y/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) {
switch (axis) {
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) {
double c = point ? 0.5 : 0;
double dx = x - c, dy = y - c, dz = z - c;
@@ -450,7 +408,7 @@ public final class ElementIntersector {
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) {
rotate(out, x, y, z, 0, (4 - ySteps) % 4, 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;
/**
* The result of intersecting a ray with a block's geometry: the world-space hit point and normal,
* the sampled ARGB color (before shading/tinting), the face's tint index ({@code -1} = none), whether the
* surface is drawn additively (emissive energy-swirl layers: its colour is added to what lies behind) and whether
* it skips directional face shading (entity layers without cardinal lighting: eyes, breeze wind).
* A ray hit on block or entity geometry: world-space point and normal, sampled ARGB texel (before shading/tint),
* tint index (-1 = none), additive (emissive energy swirl, added to what lies behind) and unshaded (no cardinal
* lighting: eyes, breeze wind).
*/
public record FaceHit(
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;
/**
* Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): fluids are not block models but
* polygons built per cell from the fluid state and its neighbours: a top surface whose four corner heights come
* from the cell's level (amount / 9, source 8/9, full when the same fluid lies above) averaged with the
* neighbours ({@code calculateAverageHeight}, split into the two triangles NW-SW-SE / NW-SE-NE), side faces
* against every neighbour that is not the same fluid and does not cover the face, and a bottom face. Top and
* side faces are double-sided (the top only when {@code shouldRenderBackwardUpFace}); side faces against
* {@code HalfTransparentBlock}/{@code LeavesBlock} use the water overlay texture and are one-sided; the bottom is
* 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).
* Water and lava like vanilla 26.3 {@code FluidRenderer} (decompiled client): not block models but polygons built
* per cell from the fluid state and its neighbours. The top surface has four corner heights (amount / 9, full
* under the same fluid, averaged with the neighbours like {@code calculateAverageHeight}) split into the triangles
* NW-SW-SE / NW-SE-NE; side faces against every neighbour that is not the same fluid and does not cover the face;
* a bottom face. Top and sides are double-sided (the top only if {@code shouldRenderBackwardUpFace}), except side
* faces against {@code HalfTransparentBlock}/{@code LeavesBlock}, which use the overlay texture one-sided; the
* bottom is seen from below only. Waterlogged blocks and seagrass/kelp/bubble columns hold a water source.
*/
public final class FluidRenderer {
public static final int NONE = 0;
public static final int WATER = 1;
public static final int LAVA = 2;
static final int WATER = 1;
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 double EPS = 1e-9;
@@ -47,13 +42,31 @@ public final class FluidRenderer {
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 =
materials("SEAGRASS", "TALL_SEAGRASS", "KELP", "KELP_PLANT", "BUBBLE_COLUMN");
// Vanilla HalfTransparentBlock / LeavesBlock: water draws its side faces against them with the overlay texture,
// one-sided (FluidRenderer isOverlay). Blue ice is one too but occludes (its face is culled anyway).
private static final EnumSet<Material> OVERLAY_NEIGHBOURS = buildOverlayNeighbours();
// Vanilla HalfTransparentBlock / LeavesBlock (FluidRenderer isOverlay). Blue ice too, but it occludes anyway.
private static final EnumSet<Material> OVERLAY_NEIGHBOURS = EnumSet.noneOf(Material.class);
// 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) {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
@@ -67,30 +80,12 @@ public final class FluidRenderer {
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) -------------
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. */
public static int state(BlockData data) {
static int state(BlockData data) {
Material m = data.getMaterial();
if (m == Material.AIR) return 0;
if (m == Material.WATER || m == Material.LAVA) {
@@ -106,7 +101,7 @@ public final class FluidRenderer {
return 0;
}
public static int type(int state) {
static int type(int state) {
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
* fluid, when the eye is below its camera height ({@code FluidState.getHeightForCamera}: 1 for a source under a
* block with a sturdy bottom face or with the same fluid above, else amount / 9).
* fluid when the eye is below its {@code FluidState.getHeightForCamera} (1 under the same fluid or for a source
* under a sturdy bottom face, else amount / 9).
*/
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);
@@ -134,14 +129,7 @@ public final class FluidRenderer {
int type = type(s);
if (type == NONE) return NONE;
BlockData above = level.at(bx, by + 1, bz);
float h;
if (type(state(above)) == type) {
h = 1;
} else if (source(s) && sturdyBottom(above)) {
h = 1;
} else {
h = ownHeight(s);
}
float h = type(state(above)) == type || (source(s) && sturdyBottom(above)) ? 1 : ownHeight(s);
double rel = y - by;
// Water: strictly below the surface; lava (near-plane check): at or below it.
return type == WATER ? (rel < h ? WATER : NONE) : (rel <= h ? LAVA : NONE);
@@ -159,11 +147,11 @@ public final class FluidRenderer {
// --- 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
* this cell up to the topmost water block of the column (water costs one level, non-occluding blocks below the
* surface such as glass at least one). Cells without water above them (in the next 15 blocks) get 15.
* Vanilla sky light level of the cell (x, y, z) under water: 15 minus one per block from this cell up to the
* topmost water block of the column (water and non-occluding blocks below the surface cost one level each).
* 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;
for (int i = 0; i < 15; i++) {
BlockData d = level.at(x, y + i, z);
@@ -181,12 +169,13 @@ public final class FluidRenderer {
private final BlockModelRegistry registry;
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
// shape of blocks that can occlude). Identity keys (ResolvedModel is cached per BlockData).
// Bit per Direction: the model covers that whole cell face with opaque texels (approximates vanilla's face
// occlusion shape). Identity keys: ResolvedModel is cached per BlockData.
private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>();
public FluidRenderer(BlockModelRegistry registry, TextureCache textures) {
FluidRenderer(BlockModelRegistry registry) {
this.registry = registry;
TextureCache textures = registry.textures();
this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1);
this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA);
this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5);
@@ -210,9 +199,12 @@ public final class FluidRenderer {
// --- face occlusion ------------------------------------------------------------------------------------
private int fullFaces(BlockData data) {
Material m = data.getMaterial();
if (m.isAir()) return 0;
return fullFaces.computeIfAbsent(registry.get(data), FluidRenderer::computeFullFaces);
if (data.getMaterial().isAir()) return 0;
return fullFaces(registry.get(data));
}
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). */
@@ -253,14 +245,18 @@ public final class FluidRenderer {
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) {
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 -------------------------------------------------------------------------------------------
/** 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) {
if (type(state) == type) {
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));
}
/** FluidRenderer.calculateAverageHeight (weights: 10 for heights >= 0.8, 1 below, solids (-1) none). */
private static float averageHeight(
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;
float[] w = new float[2];
float corner = -1.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;
addWeighted(w, corner);
}
addWeighted(w, self);
addWeighted(w, h1);
addWeighted(w, h2);
return w[0] / w[1];
float wc = weight(corner), ws = weight(self), w1 = weight(h1), w2 = weight(h2);
// Same summation order as vanilla's running sums.
return (0.0F + corner * wc + self * ws + h1 * w1 + h2 * w2) / (0.0F + wc + ws + w1 + w2);
}
private static void addWeighted(float[] w, float h) {
if (h >= 0.8F) {
w[0] += h * 10.0F;
w[1] += 10.0F;
} else if (h >= 0.0F) {
w[0] += h;
w[1]++;
}
private static float weight(float h) {
return h >= 0.8F ? 10.0F : h >= 0.0F ? 1.0F : 0.0F;
}
// --- 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,
* which does not change the texture angle). {0, 0} = still texture.
* Texture angle of the flowing top sprite from FlowingFluid.getFlow (horizontal part; the falling tweak only adds
* 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;
float own = ownHeight(state);
int[][] dirs = {{0, -1}, {1, 0}, {0, 1}, {-1, 0}};
for (int[] d : dirs) {
for (int[] d : HORIZONTAL) {
BlockData nd = level.at(x + d[0], y, z + d[1]);
int ns = state(nd);
if (!affectsFlow(type, ns)) continue;
@@ -331,8 +321,8 @@ public final class FluidRenderer {
}
}
// 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};
return new double[] {fx, fz};
if (Math.sqrt(fx * fx + fz * fz) < 1.0E-5) return Float.NaN;
return (float) Math.atan2(fz, fx) - (float) (Math.PI / 2);
}
private static boolean affectsFlow(int type, int neighbourState) {
@@ -340,10 +330,9 @@ public final class FluidRenderer {
return t == NONE || t == type;
}
/** Vanilla tag blocks_fluid_flow (= #blocks_motion + #all_signs). */
private static boolean blocksFluidFlow(BlockData data) {
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. */
@@ -359,27 +348,25 @@ public final class FluidRenderer {
// --- ray crossing --------------------------------------------------------------------------------------
/** A drawn fluid polygon crossed by a ray: texel (ARGB), shade, face, outward normal and side. */
public static final class Crossing {
public double t;
public int texel;
public double shade;
public int type;
public boolean entering; // the ray comes from outside the fluid (front side)
public double nx, ny, nz; // outward normal (unit)
public int lightX, lightY, lightZ; // cell whose light (max with the one above) lights the face
/** A drawn fluid polygon crossed by a ray. */
static final class Crossing {
double t;
int texel;
boolean entering; // the ray comes from outside the fluid (front side)
double nx, ny, nz; // outward unit normal
boolean bottom; // the bottom face, lit by the cell below
}
/**
* The nearest fluid polygon of cell (bx, by, bz) (holding {@code data} with fluid {@code state}) crossed by
* the ray {@code o + t·d} with {@code t > minT}, or null.
* The nearest drawn fluid polygon of cell (bx, by, bz) (block model {@code model}, fluid {@code state}) crossed
* by the ray {@code o + t·d} with {@code t > minT}, or null.
*/
public Crossing nearest(
Crossing nearest(
BlockLookup level,
int bx,
int by,
int bz,
BlockData data,
ResolvedModel model,
int state,
double ox,
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 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);
int sDown = state(down), sUp = state(up), sNorth = state(north), sSouth = state(south);
int sWest = state(west), sEast = state(east);
boolean sameUp = type(sUp) == type, sameDown = type(sDown) == type;
int sNorth = state(north), sSouth = state(south), sWest = state(west), sEast = state(east);
boolean sameUp = type(state(up)) == type, sameDown = type(state(down)) == type;
boolean sameN = type(sNorth) == type, sameS = type(sSouth) == 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 renderDown = !sameDown && !occludes(data, Direction.DOWN) && !occludes(down, Direction.UP);
boolean renderN = !sameN && !occludes(data, Direction.NORTH);
boolean renderS = !sameS && !occludes(data, Direction.SOUTH);
boolean renderW = !sameW && !occludes(data, Direction.WEST);
boolean renderE = !sameE && !occludes(data, Direction.EAST);
boolean renderDown = !sameDown && !covers(selfFaces, Direction.DOWN) && !occludes(down, Direction.UP);
boolean renderN = !sameN && !covers(selfFaces, Direction.NORTH);
boolean renderS = !sameS && !covers(selfFaces, Direction.SOUTH);
boolean renderW = !sameW && !covers(selfFaces, Direction.WEST);
boolean renderE = !sameE && !covers(selfFaces, Direction.EAST);
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;
if (self >= 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;
if (x < -EPS || x > 1 + EPS || z < -EPS || z > 1 + 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 (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz);
if (!backUp) continue;
@@ -479,7 +466,6 @@ public final class FluidRenderer {
}
}
if (renderDown && dy > 0) {
// One-sided, seen from below only.
double t = (bottom - ly) / dy;
if (t > minT && t < bestT) {
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++) {
boolean render;
BlockData neighbour;
double plane;
Direction neighbourFace;
float h0, h1;
switch (face) {
case 2 -> {
render = renderN;
neighbour = north;
plane = OFFS;
neighbourFace = Direction.SOUTH;
h0 = hNW;
h1 = hNE;
}
case 3 -> {
render = renderS;
neighbour = south;
plane = 1 - OFFS;
neighbourFace = Direction.NORTH;
h0 = hSE;
h1 = hSW;
}
case 4 -> {
render = renderW;
neighbour = west;
plane = OFFS;
neighbourFace = Direction.EAST;
h0 = hSW;
h1 = hNW;
}
default -> {
render = renderE;
neighbour = east;
plane = 1 - OFFS;
neighbourFace = Direction.WEST;
h0 = hNE;
h1 = hSE;
}
}
if (!render) continue;
boolean alongX = face == 2 || face == 3;
boolean negative = face == 2 || face == 4;
double plane = negative ? OFFS : 1 - OFFS;
double dAxis = alongX ? dz : dx;
if (Math.abs(dAxis) < 1e-12) continue;
double t = (plane - (alongX ? lz : lx)) / dAxis;
if (!(t > minT) || t >= bestT) continue;
double outward = face == 2 || face == 4 ? -1 : 1;
double outward = negative ? -1 : 1;
boolean entering = dAxis * outward < 0;
Direction toNeighbour =
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;
if (occludes(neighbour, neighbourFace)) continue;
boolean overlay = type == WATER && OVERLAY_NEIGHBOURS.contains(neighbour.getMaterial());
if (!entering && overlay) continue;
double x = lx + dx * t, y = ly + dy * t, z = lz + dz * t;
double along = alongX ? x : z;
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 =
switch (face) {
case 2 -> x;
case 3 -> 1 - x;
case 4 -> 1 - z;
default -> z;
};
double s = alongX ? (negative ? x : 1 - x) : (negative ? 1 - z : z);
double top = h0 + (h1 - h0) * s;
if (y < bottom - EPS || y > top + EPS) continue;
bestT = t;
@@ -581,51 +554,29 @@ public final class FluidRenderer {
Crossing best = new Crossing();
best.t = bestT;
best.type = type;
best.entering = bestEntering;
best.nx = bestNx;
best.ny = bestNy;
best.nz = bestNz;
best.lightX = bx;
best.lightY = bestFace == 1 ? by - 1 : by;
best.lightZ = bz;
best.bottom = bestFace == 1;
boolean water = type == WATER;
switch (bestFace) {
case 0 -> {
double[] f = flow(level, type, bx, by, bz, state);
if (f[0] == 0 && f[1] == 0) {
best.texel = sample(water ? waterStill : lavaStill, hitX, hitZ);
} else {
// 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 u = 0.5 - cs - sn + 2 * cs * hitX + 2 * sn * hitZ;
double v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ;
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;
int[][] still = water ? waterStill : lavaStill, flow = water ? waterFlow : lavaFlow;
if (bestFace == 0) {
float angle = flowAngle(level, type, bx, by, bz, state);
if (Float.isNaN(angle)) {
best.texel = sample(still, hitX, hitZ);
} else {
// Flowing sprite turned to the flow (vanilla UVs at the corners; affine over the cell).
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 v = 0.5 - cs + sn - 2 * sn * hitX + 2 * cs * hitZ;
best.texel = sample(flow, u, v);
}
} 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;
}
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;
/**
* Traces a single ray against a {@link WorldSnapshot}, sampling block models via the
* {@link ElementIntersector} and applying biome tint, directional face shading, alpha blending of
* translucent surfaces, liquid transparency and reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads.
* Traces a single ray against a {@link WorldSnapshot}: block models ({@link ElementIntersector}), fluid polygons
* ({@link FluidRenderer}) and entities, with biome tint, face shading, ambient occlusion, alpha blending of
* translucent surfaces, fog and water reflection. No Bukkit world access, so it runs on worker threads.
*/
public final class SnapshotRaytracer {
private static final int BIOME_BLEND_RADIUS = 2;
// Distance fog (atmospheric perspective).
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.
// 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};
// Alpha blending: texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones (stained glass, ice,
// slime outer layer, text-display backgrounds) are blended over what lies behind them. Bounded so a
// stack of glass costs at most a few extra intersection rounds per ray.
// Texels with alpha >= OPAQUE_ALPHA end the ray; translucent ones are blended over what lies behind them.
// Bounded so a stack of glass costs at most a few extra intersection rounds per ray.
private static final int OPAQUE_ALPHA = 250;
private static final int MAX_TRANSLUCENT_LAYERS = 6;
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.
private static final double ADDITIVE = -1;
// Water reflection (not vanilla; kept as before): weight of the reflected ray on the first water surface hit
// from above (rays starting in the air only).
// Water reflection (not vanilla): weight of the reflected ray on the first water surface entered from the air.
private static final double WATER_REFLECTION = 0.10;
// Brightness by sky light level (index): vanilla lightmap (lightmap.fsh: level / (4 - 3·level), mixed halfway
// with notGamma = default brightness option 0.5, overworld ambient 0). Sky light loses one level per water
// block (water's light block = 1), so surfaces under water get darker with VERTICAL depth (FluidRenderer
// .skyLevel); PixelPics has no other light model, so this only applies under water.
// Brightness by sky light level: vanilla lightmap.fsh (level / (4 - 3·level), mixed halfway with notGamma at the
// default brightness 0.5, overworld ambient 0). PixelPics has no other light model, so it only matters under
// water, where sky light drops by vertical water depth (FluidRenderer.skyLevel). SKY_LIGHT[15] == 1.
private static final double[] SKY_LIGHT = new double[16];
static {
@@ -80,7 +72,7 @@ public final class SnapshotRaytracer {
double maxDistance,
int reflectionDepth) {
this.registry = registry;
this.fluids = new FluidRenderer(registry, registry.textures());
this.fluids = new FluidRenderer(registry);
this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer;
this.maxDistance = maxDistance;
@@ -88,16 +80,11 @@ public final class SnapshotRaytracer {
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) {
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(
WorldSnapshot snapshot,
Vector origin,
@@ -119,26 +106,25 @@ public final class SnapshotRaytracer {
double ox = origin.getX(), oy = origin.getY(), oz = origin.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);
boolean entities = scene != null && !scene.isEmpty();
// 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;
double[] layerAlpha = null, layerT = null;
boolean[] layerLit = null;
int layers = 0;
double transmittance = 1;
int finalColor = skyColor;
int finalColor = 0;
double finalT = Double.POSITIVE_INFINITY; // sky
boolean finalLit = false;
double minT = 0;
BlockHit pending = null; // nearest block surface not consumed yet
// Walk the surfaces along the ray in depth order (blocks, fluid faces and entities interleaved);
// translucent ones are collected as layers and the ray continues behind them, the first opaque one ends it.
// Surfaces in depth order (blocks, fluid faces and entities interleaved): translucent ones become layers,
// the first opaque one ends the ray.
while (true) {
if (pending == null) pending = blocks.next(minT);
double limit = pending != null ? pending.t : maxDistance;
@@ -148,8 +134,7 @@ public final class SnapshotRaytracer {
boolean additive = false;
boolean lit = false;
if (eh != null) {
// Emissive additive surfaces (energy swirl) and layers without cardinal lighting (eyes, breeze
// wind) are unshaded: vanilla NO_CARDINAL_LIGHTING (+ EMISSIVE).
// Vanilla NO_CARDINAL_LIGHTING: additive energy swirl, eyes, breeze wind.
additive = eh.additive();
boolean flat = additive || eh.unshaded();
color = flat ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
@@ -165,8 +150,7 @@ public final class SnapshotRaytracer {
int alpha = ColorUtil.alpha(color);
double a = alpha / 255.0;
if (additive) {
// Additive light never hides what lies behind it: it neither ends the ray nor dims it. Beyond the
// layer budget it is dropped.
// Additive light neither ends nor dims the ray; beyond the layer budget it is dropped.
if (layers == MAX_TRANSLUCENT_LAYERS) {
minT = t + LAYER_EPS;
continue;
@@ -174,7 +158,6 @@ public final class SnapshotRaytracer {
} else if (alpha >= OPAQUE_ALPHA
|| layers == MAX_TRANSLUCENT_LAYERS
|| transmittance * (1 - a) < MIN_TRANSMITTANCE) {
// Opaque, or what lies behind would contribute next to nothing: this surface ends the ray.
finalColor = color;
finalT = t;
finalLit = lit;
@@ -195,51 +178,35 @@ public final class SnapshotRaytracer {
minT = t + LAYER_EPS;
}
// Compose back to front: final surface, translucent layers (fluid faces among them), each fogged.
// Surfaces under water are dimmed by their depth like vanilla sky light.
// Compose back to front, each surface fogged; surfaces under water dimmed by their sky light. The sky
// 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;
if (Double.isInfinite(finalT)) {
color = medium.inFluid() ? medium.sky(finalColor, dx, dy, dz) : finalColor;
color = medium.sky(skyColor, dx, dy, dz);
} else {
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--) {
if (layerAlpha[i] == ADDITIVE) {
// Added light fades with distance like the surface it would otherwise be.
double f = medium.inFluid() ? medium.fogValue(layerT[i]) : fogFactor(layerT[i]);
double f = medium.fogValue(layerT[i]);
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f));
} else {
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;
}
/** Distance fog: the camera fluid's fog inside water/lava, the atmospheric one otherwise. */
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).
*/
/** A surface found by the {@link BlockCursor}; {@code lit}: carries its own sky light (fluid faces). */
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
* of the last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces
* further inside the same block (slime/honey block cores, the far side of a pane, the water polygons of a
* 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.
* Walks the voxel grid along one ray and hands out block surfaces in depth order. It stays on the voxel of the
* last returned hit, so a later call (with {@code minT} behind that hit) also finds surfaces further inside the
* same block (slime/honey cores, the far side of a pane, the water polygons of a waterlogged block).
*/
private final class BlockCursor {
private final WorldSnapshot snapshot;
@@ -249,11 +216,13 @@ public final class SnapshotRaytracer {
private final int depth;
private final CameraMedium medium;
private final double ox, oy, oz, dx, dy, dz;
private final FluidRenderer.BlockLookup blocks;
private final VoxelDDA dda;
private int steps;
private boolean done;
// 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 reflected;
@@ -277,6 +246,7 @@ public final class SnapshotRaytracer {
this.dx = direction.getX();
this.dy = direction.getY();
this.dz = direction.getZ();
this.blocks = snapshot::getBlockData;
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
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);
int fluidState = FluidRenderer.state(data);
if (fluidState != 0) {
if (FluidRenderer.type(fluidState) == FluidRenderer.WATER) sawWater = true;
FluidRenderer.Crossing c = fluids.nearest(
snapshot::getBlockData, bx, by, bz, data, fluidState, ox, oy, oz, dx, dy, dz, minT);
if (c != null && (hit == null || c.t < hit.t())) return fluidHit(c, bx, by, bz);
int type = FluidRenderer.type(fluidState);
if (type == FluidRenderer.WATER) sawWater = true;
FluidRenderer.Crossing c =
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) {
advance();
continue;
}
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
if (ColorUtil.alpha(color) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) {
minT = hit.t(); // inner face between two blocks of the same translucent kind: not drawn
// Shading and tint keep the texel alpha, so the culling test can run first.
if (ColorUtil.alpha(hit.color()) < OPAQUE_ALPHA && culledByNeighbour(hit, data, bx, by, bz)) {
minT = hit.t();
continue;
}
return new BlockHit(color, hit.t(), false);
return new BlockHit(shadeAndTint(hit, data, snapshot, bx, by, bz), hit.t(), false);
}
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
* face's cell (max with the one above, like vanilla's light coords; water only, lava glows). The first
* water surface entered from the air also reflects (folded into the layer colour/alpha).
* A crossed fluid polygon as a surface: texel × biome water colour × face shade × sky light of the face's
* cell (the one below for the bottom face; max with the cell above, like vanilla's light coords). Lava is
* 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;
boolean water = c.type == FluidRenderer.WATER;
boolean water = type == FluidRenderer.WATER;
double light = 1;
if (water) {
BiomeTint tint = blendedTint(snapshot, bx, by, bz);
if (tint != null) color = ColorUtil.multiply(color, tint.water());
int y = c.bottom ? by - 1 : by;
int level = Math.max(
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY, c.lightZ),
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY + 1, c.lightZ));
FluidRenderer.skyLevel(blocks, bx, y, bz), FluidRenderer.skyLevel(blocks, bx, y + 1, bz));
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()) {
reflected = true;
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 reflectStart = new Vector(px, py, pz).add(normal.clone().multiply(1e-3));
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 a2 = 1 - (1 - a) * (1 - r);
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
* light level of the block in front of the surface (see {@link FluidRenderer#skyLevel}). Unchanged when the
* ray never met water.
* {@code color} of a surface at ray parameter {@code t} dimmed by the sky light of the block in front of it
* (back along the ray), when the ray met water.
*/
int underwaterLight(int color, double t) {
if (!sawWater || Double.isInfinite(t)) return color;
// A point just in front of the surface (back along the ray), i.e. in the block that lights it.
if (!sawWater) return color;
double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
int bx = (int) Math.floor(ox + dx * (t - back));
int by = (int) Math.floor(oy + dy * (t - back));
int bz = (int) Math.floor(oz + dz * (t - back));
int 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]);
}
/**
* Vanilla skips the faces between two translucent blocks of the same kind (stained glass walls, ice,
* connected panes): true when the hit lies on the block boundary and the neighbour across it is the
* same block type.
* Vanilla skips the faces between two translucent blocks of the same kind (HalfTransparentBlock: stained
* glass walls, ice, connected panes): the hit lies on the block boundary and the neighbour is the same type.
*/
private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) {
Vector n = hit.normal();
@@ -420,93 +388,64 @@ public final class SnapshotRaytracer {
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
* touching that corner (in the layer just outside the face) are solid, bilinearly interpolated
* across the face. Only applied to axis-aligned faces.
* Smooth ambient occlusion like vanilla: each face corner is darkened by how many of the three blocks touching
* it (in the layer just outside the face) are solid, bilinearly interpolated across the face. Axis-aligned
* faces only.
*/
private double ambientOcclusion(FaceHit hit, WorldSnapshot snapshot, int bx, int by, int bz) {
double nx = hit.normal().getX(),
ny = hit.normal().getY(),
nz = hit.normal().getZ();
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 ly = hit.point().getY() - by;
double lz = hit.point().getZ() - bz;
// Offset to the layer just outside the face, plus the two in-plane unit axes and face coords.
int ofx = (int) Math.round(nx), ofy = (int) Math.round(ny), ofz = (int) Math.round(nz);
int ux, uy, uz, vx, vy, vz;
// In-plane axes u = (ux, 0, uz), v = (0, vy, vz) and the face coordinates along them.
int ux, uz, vy, vz;
double su, sv;
if (ay > 0.5) { // up/down
ux = 1;
uy = 0;
uz = 0;
vx = 0;
vy = 0;
vz = 1;
su = lx;
sv = lz;
} else if (ax > 0.5) { // east/west
ux = 0;
uy = 0;
uz = 1;
vx = 0;
vy = 1;
vz = 0;
su = lz;
sv = ly;
} else { // north/south
ux = 1;
uy = 0;
uz = 0;
vx = 0;
vy = 1;
vz = 0;
su = lx;
sv = ly;
}
double b00 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, -1);
double b10 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, -1);
double b01 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, -1, +1);
double b11 = aoCorner(snapshot, bx, by, bz, ofx, ofy, ofz, ux, uy, uz, vx, vy, vz, +1, +1);
// The cell just outside the face.
int x = bx + (int) Math.round(nx), y = by + (int) Math.round(ny), z = bz + (int) Math.round(nz);
boolean uMinus = solid(snapshot, x - ux, y, z - uz), uPlus = solid(snapshot, x + ux, y, z + uz);
boolean vMinus = solid(snapshot, x, y - vy, z - vz), vPlus = solid(snapshot, x, y + vy, z + vz);
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 bottom = b01 + (b11 - b01) * su;
return top + (bottom - top) * sv;
}
private double aoCorner(
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);
private static double aoBrightness(boolean side1, boolean side2, boolean corner) {
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) {
@@ -514,11 +453,7 @@ public final class SnapshotRaytracer {
return m != Material.AIR && m.isOccluding();
}
/**
* 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.
*/
/** Biome tint averaged over the (2r+1)² columns around the block (vanilla biome blend, radius 2); cached per column. */
private BiomeTint blendedTint(WorldSnapshot snapshot, int bx, int by, int bz) {
long key = (((long) bx) & 0xFFFFFFFFL) | (((long) bz) << 32);
BiomeTint cached = tintCache.get(key);
@@ -545,21 +480,23 @@ public final class SnapshotRaytracer {
}
private static void accumulate(long[] acc, int argb) {
acc[0] += (argb >> 16) & 0xFF;
acc[1] += (argb >> 8) & 0xFF;
acc[2] += argb & 0xFF;
acc[0] += ColorUtil.red(argb);
acc[1] += ColorUtil.green(argb);
acc[2] += ColorUtil.blue(argb);
}
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 double shadeFactor(Vector normal) {
double ax = Math.abs(normal.getX());
double ay = Math.abs(normal.getY());
double az = Math.abs(normal.getZ());
if (ay >= ax && ay >= az) return normal.getY() >= 0 ? 1.0 : 0.5;
private static double shadeFactor(Vector normal) {
return shadeFactor(normal.getX(), normal.getY(), normal.getZ());
}
/** Vanilla directional shading by the dominant normal axis: up 1.0, down 0.5, north/south 0.8, east/west 0.6. */
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;
return 0.6;
}
@@ -1,23 +1,19 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
/**
* Amanatides-Woo voxel traversal: walks the integer block grid a ray passes through, in order,
* without any Bukkit world access (safe to run off the main thread).
*/
public final class VoxelDDA {
/** Amanatides-Woo voxel traversal: the integer block cells a ray passes through, in order. */
final class VoxelDDA {
public int x, y, z;
public double tCurrent; // ray parameter at which the current voxel was entered
int x, y, z;
double tCurrent; // ray parameter at which the current voxel was entered
private final int stepX, stepY, stepZ;
private final double tDeltaX, tDeltaY, tDeltaZ;
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.y = (int) Math.floor(oy);
this.z = (int) Math.floor(oz);
this.tCurrent = 0;
this.stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
this.stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
@@ -38,8 +34,7 @@ public final class VoxelDDA {
return (next - origin) / dir;
}
/** Advance to the next voxel along the ray, updating {@link #tCurrent}. */
public void advance() {
void advance() {
if (tMaxX < tMaxY) {
if (tMaxX < tMaxZ) {
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
* 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, which is vertical too); the
* horizontal FOV follows from the image aspect ratio. Square photos: 70° both ways.
*/
/** Vertical field of view in degrees (vanilla's default FOV option is vertical too). */
public static final double V_FOV_DEG = 70;
// Camera.getFov in water/lava: fov · lerp(fovEffectScale = 1, 1, 6/7).
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 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 final SnapshotRaytracer raytracer;
@@ -109,22 +106,14 @@ public class DefaultScreenRenderer implements Renderer {
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. */
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
int superW = resolution.getWidth() * SSAA;
int superH = resolution.getHeight() * SSAA;
// Vanilla Camera.getFov: in water or lava the FOV shrinks to 6/7 (FOV effects slider at its default 100%).
World eyeWorld = eyeLocation.getWorld();
boolean inFluid = eyeWorld != null
&& FluidRenderer.cameraFluid(
eyeWorld::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
!= FluidRenderer.NONE;
int superW = resolution.width() * SSAA;
int superH = resolution.height() * SSAA;
World world = eyeLocation.getWorld();
boolean inFluid = FluidRenderer.cameraFluid(
world::getBlockData, eyeLocation.getX(), eyeLocation.getY(), eyeLocation.getZ())
!= FluidRenderer.NONE;
List<Vector> rayMap =
buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
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<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
World world = eyeLocation.getWorld();
long dayTime = world.getTime();
long fullTime = world.getFullTime();
int moonPhase = (int) (fullTime / 24000L % 8L);
@@ -144,8 +132,8 @@ public class DefaultScreenRenderer implements Renderer {
snapshot,
rayMap,
eyeLocation.toVector(),
resolution.getWidth(),
resolution.getHeight(),
resolution.width(),
resolution.height(),
sky,
entities,
blockEntities,
@@ -159,9 +147,8 @@ public class DefaultScreenRenderer implements Renderer {
}
/**
* As {@link #execute(RenderJob)}, but cooperatively abortable: once {@code cancelled} is set, the
* parallel loops short-circuit (remaining work becomes a no-op) so a stuck/overlong render drains
* out quickly. The returned image is then partial and should be discarded by the caller.
* As {@link #execute(RenderJob)}, but abortable: once {@code cancelled} is set the remaining work is skipped and
* the returned image is partial (to be discarded).
*/
public BufferedImage execute(RenderJob job, AtomicBoolean cancelled) {
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,
* low-priority pool (parallel streams adopt the pool of the running fork-join worker); without one
* it runs inline on the common pool (used by the offline render tools).
* Runs a parallel-stream task in the {@link #tracePool} (parallel streams adopt the pool of the running fork-join
* worker), or on the common pool without one (offline render tools).
*/
private void runParallel(Runnable task) {
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
* of the eye, {@code ray = forward + u·tan(fovX/2)·right + v·tan(fovY/2)·up} with u, v ∈ [-1, 1] at
* the pixel centres, row 0 = top. The vertical FOV is fixed ({@link #V_FOV_DEG}, like vanilla's
* 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.
* Pinhole camera like vanilla's projection: {@code ray = forward + u·tan(fovX/2)·right + v·tan(fovY/2)·up} with
* 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 yaw 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) {
return buildRayMap(eye, width, height, V_FOV_DEG);
@@ -10,9 +10,8 @@ import java.util.List;
import org.bukkit.util.Vector;
/**
* A prepared render: the world snapshot (captured on the main thread) plus the ray map, camera origin,
* the sky context (time of day / moon phase) and the captured entity, block-entity and decoration
* (painting / item frame) states. {@link DefaultScreenRenderer#execute} can run this off the main thread.
* A prepared render: everything captured on the main thread (world snapshot, ray map, camera origin, sky state,
* entity/block-entity/decoration/object states). {@link DefaultScreenRenderer#execute} runs it off the main thread.
*/
public record RenderJob(
WorldSnapshot snapshot,
@@ -24,44 +23,4 @@ public record RenderJob(
List<EntityState> entities,
List<BlockEntityState> blockEntities,
List<DecorationState> decorations,
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());
}
}
List<ObjectState> objects) {}
@@ -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;
public final class Resolution {
/** Output size of a photo in pixels. */
public record Resolution(int width, int height) {
private final int width;
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) {
public Resolution {
Preconditions.checkArgument(width > 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;
/**
* Computes a time-of-day dependent sky color for rays that escape the world: a day/night gradient
* with twilight glow, the sun and moon (with phase), stars at night and a procedural cloud layer.
* All inputs are immutable ({@link SkyContext} + captured textures), so it is thread safe.
* Sky colour of rays that escape the world (not vanilla's sky renderer): day/night gradient with twilight glow, sun
* and moon (with phase), stars and vanilla's cloud layer. Immutable, so thread safe.
*/
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_SPEED = 0.03; // blocks per tick, drift along +X
private static final double SUN_HALF = 0.085; // angular half-size (radians)
private static final double MOON_HALF = 0.075;
// sin of the angular half-size.
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).
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 TWI_PURPLE = rgb(80, 42, 92);
private final int[][] sunTexture;
private final int[][] moonTexture;
private final int[][] cloudTexture;
public SkyRenderer(TextureCache textures) {
this.sunTexture =
textures.get(ResourceLocation.parse("environment/sun")).orElse(null);
this.moonTexture =
textures.get(ResourceLocation.parse("environment/moon_phases")).orElse(null);
this.cloudTexture =
@@ -50,9 +47,8 @@ public final class SkyRenderer {
dy /= len;
dz /= len;
// Sun/moon position, derived exactly from Minecraft's sky transforms:
// celestialAngle ca = getTimeOfDay(dayTime); the sun is rotated by ca*360deg about the X axis
// (after a -90deg Y rotation), giving sunDir = (-sin(2*pi*ca), cos(2*pi*ca), 0) in world space.
// Vanilla sun direction: rotated by celestialAngle·360° about X (after -90° about Y)
// = (-sin(2π·ca), cos(2π·ca), 0).
double ca = celestialAngle(ctx.dayTime());
double ang = ca * 2 * Math.PI;
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 color = lerp(nightColor, dayColor, dayFactor);
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith),
// strongest while the sun is near the horizon and warmer toward its azimuth. Matches vanilla.
// Sunrise/sunset: a full-sky warm wash (orange at the horizon -> red -> purple at the zenith), strongest while
// the sun is near the horizon and warmer toward its azimuth.
double twilight = Math.clamp(1 - Math.abs(sunY) / 0.45, 0, 1);
if (twilight > 0) {
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);
if (star > 0) {
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) {
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) {
color = overlayDisc(
color, dx, dy, dz, -sunX, -sunY, 0, MOON_HALF, moonTexture, rgb(228, 228, 238), ctx.moonPhase());
if (sunY < 0.15) {
color = overlayDisc(color, dx, dy, dz, -sunX, -sunY, 0, MOON_SIN_HALF, rgb(228, 228, 238), ctx.moonPhase());
}
// Cloud layer: the ray crosses the cloud plane at y = CLOUD_HEIGHT; the world hit point is
// mapped to a clouds.png texel exactly as vanilla does (see clouds()). Horizontal drift uses
// the world time (fullTime * CLOUD_SPEED along +X).
// Cloud layer at y = CLOUD_HEIGHT, drifting along +X with the world time.
if (dy > 0.02 && origin.getY() < CLOUD_HEIGHT) {
double t = (CLOUD_HEIGHT - origin.getY()) / dy;
double cx = origin.getX() + dx * t + ctx.fullTime() * CLOUD_SPEED;
@@ -126,7 +117,11 @@ public final class SkyRenderer {
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(
int base,
double dx,
@@ -135,15 +130,12 @@ public final class SkyRenderer {
double cx,
double cy,
double cz,
double half,
int[][] texture,
double sinHalf,
int solid,
int moonPhase) {
double cos = dx * cx + dy * cy + dz * cz;
if (cos <= 0) return base;
double sinHalf = Math.sin(half);
// Local disc coordinates: project the direction onto the plane around the body axis.
// right = normalize(body x worldUp); discUp = right x body
// Disc coordinates: right = normalize(body x worldUp), discUp = right x body.
double crx = cz, cry = 0, crz = -cx;
double crl = Math.sqrt(crx * crx + crz * crz);
if (crl < 1e-6) {
@@ -155,25 +147,20 @@ public final class SkyRenderer {
crx /= crl;
cry /= crl;
crz /= crl;
// discUp = right cross body
double ux = cry * cz - crz * cy;
double uy = crz * cx - crx * cz;
double uz = crx * cy - cry * cx;
double u = dx * crx + dy * cry + dz * crz;
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
if (m > 1) return base;
double su = u / 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;
if (moonPhase >= 0 && texture != null && texture.length > 0) {
alpha = bodyAlpha(texture, su, sv, moonPhase) > 80 ? 1.0 : 0.0; // phase shape
if (moonPhase >= 0 && moonTexture != null && moonTexture.length > 0) {
alpha = bodyAlpha(moonTexture, su, sv, moonPhase) > 80 ? 1.0 : 0.0;
} else {
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);
}
/** 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) {
int h = texture.length;
int w = texture[0].length;
double u = su, v = 1 - sv; // texture v is top-down
int col = moonPhase % 4;
int row = (moonPhase / 4) % 2;
int row = moonPhase / 4;
u = (col + u) / 4.0;
v = (row + v) / 2.0;
int px = clamp((int) (u * w), 0, w - 1);
int py = clamp((int) (v * h), 0, h - 1);
int px = Math.clamp((int) (u * w), 0, w - 1);
int py = Math.clamp((int) (v * h), 0, h - 1);
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
* each texel covering a {@link #CLOUD_CELL} (=12) block square, so the pattern repeats every
* 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.
* Vanilla cloud coverage: {@code clouds.png} tiled over the world, one texel per {@link #CLOUD_CELL} block square
* (U along X, V along Z); a texel with alpha is a cloud. Value noise only when the pack lacks the texture.
*/
private double clouds(double x, double z) {
if (cloudTexture != null && cloudTexture.length > 0) {
@@ -250,8 +232,6 @@ public final class SkyRenderer {
return h ^ (h >>> 16);
}
// --- small color/math helpers ---
private static int rgb(int r, int g, int b) {
return (r << 16) | (g << 8) | b;
}
@@ -261,13 +241,6 @@ public final class SkyRenderer {
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). */
private static double celestialAngle(long dayTime) {
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);
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;
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).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);