alpha-blend translucent block and entity texels (stained glass, slime skin, text backgrounds)

This commit is contained in:
MineTec committed 2026-11-04 13:49:45 +01:00
1 parent 37fa1d8f56
commit 9a6c8b538d
8 files changed
+283 -110

No files matched your search

@@ -10,7 +10,7 @@ import org.bukkit.util.Vector;
/**
* Intersects a world-space ray with a single {@link EntityCube} (oriented box). The ray is mapped
* into the cube's local frame, slab-tested, and the entry face is sampled. Fully transparent texels
* are treated as holes (alpha cutout). The returned {@code t} is a world-space distance.
* are treated as holes (alpha cutout); partially transparent ones keep their alpha for the tracer to blend. The returned {@code t} is a world-space distance.
*/
public final class EntityIntersector {
@@ -18,7 +18,12 @@ public final class EntityIntersector {
private EntityIntersector() {}
public static FaceHit intersect(EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz) {
/**
* Nearest drawn face of the cube with {@code t > minT} (entry face first, else the far face); {@code minT}
* lets the tracer continue behind a translucent hit.
*/
public static FaceHit intersect(
EntityCube cube, double ox, double oy, double oz, double dx, double dy, double dz, double minT) {
double[] o = cube.toLocal.apply(ox, oy, oz);
double[] d = cube.toLocal.applyLinear(dx, dy, dz);
@@ -49,11 +54,12 @@ public final class EntityIntersector {
}
if (tmin > tmax + EPS) return null;
}
if (inAxis < 0 || tmax < EPS) return null;
double lo = Math.max(EPS, minT);
if (inAxis < 0 || tmax <= lo) return null;
// Vanilla draws entity models without back-face culling: a ray looking through a transparent
// texel of the entry face still sees the inside of the far face (and both sides of a plane).
if (tmin >= EPS) {
if (tmin > lo) {
FaceHit hit = sample(cube, o, d, tmin, inAxis, inNeg, ox, oy, oz, dx, dy, dz);
if (hit != null) return hit;
}
@@ -58,16 +58,17 @@ public final class EntityScene {
return entities.isEmpty();
}
/** Nearest entity hit with {@code t < maxT}, or null. */
public FaceHit nearestHit(double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
/** Nearest entity hit with {@code minT < t < maxT}, or null. */
public FaceHit nearestHit(
double ox, double oy, double oz, double dx, double dy, double dz, double minT, double maxT) {
FaceHit best = null;
double bestT = maxT;
for (RenderedEntity e : entities) {
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
if (!rayAabb(e.aabbMin, e.aabbMax, ox, oy, oz, dx, dy, dz, minT, bestT)) continue;
for (EntityCube cube : e.cubes) {
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, bestT)) continue;
FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz);
if (hit != null && hit.t() > EPS && hit.t() < bestT) {
if (!rayAabb(cube.aabbMin, cube.aabbMax, ox, oy, oz, dx, dy, dz, minT, bestT)) continue;
FaceHit hit = EntityIntersector.intersect(cube, ox, oy, oz, dx, dy, dz, minT);
if (hit != null && hit.t() > EPS && hit.t() > minT && hit.t() < bestT) {
best = hit;
bestT = hit.t();
}
@@ -77,8 +78,17 @@ public final class EntityScene {
}
private static boolean rayAabb(
double[] min, double[] max, double ox, double oy, double oz, double dx, double dy, double dz, double maxT) {
double tmin = 0, tmax = maxT;
double[] min,
double[] max,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double minT,
double maxT) {
double tmin = Math.max(0, minT), tmax = maxT;
double[] o = {ox, oy, oz}, d = {dx, dy, dz};
for (int a = 0; a < 3; a++) {
if (Math.abs(d[a]) < EPS) {
@@ -17,8 +17,6 @@ import java.util.List;
*/
public final class ObjectBaker implements EntityBaker<ObjectState> {
private static final int MIN_BACKGROUND_ALPHA =
0x80; // translucent text backgrounds can't be drawn: skip faint ones
// Vanilla leash colours (LeashRenderer): brown, alternating with a darker shade per segment.
private static final int LEASH_LIGHT = 0xFF7F6650, LEASH_DARK = 0xFF59473A;
private static final int LEASH_SEGMENTS = 24;
@@ -120,8 +118,9 @@ public final class ObjectBaker implements EntityBaker<ObjectState> {
new EntityCube(new double[] {x0, y0, 0.05}, new double[] {x1, y1, 0.05}, card(text), s.toWorld()));
}
int bg = s.backgroundArgb();
if ((bg >>> 24) >= MIN_BACKGROUND_ALPHA && (w > 0 || !s.lines().isEmpty())) {
int[][] solid = {{bg | 0xFF000000}};
// Translucent backgrounds (vanilla default: 25% black) keep their alpha; the tracer blends them.
if (ColorUtil.opaque(bg) && (w > 0 || !s.lines().isEmpty())) {
int[][] solid = {{bg}};
cubes.add(new EntityCube(
new double[] {-w / 2.0 - 1, -1, 0}, new double[] {w / 2.0 + 1, h, 0}, card(solid), s.toWorld()));
}
@@ -159,7 +159,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
if (vanilla) addVanillaLayers(s, ch, pose, show, baked);
if (vanilla) addVanillaLayers(s, ch, pose, show, tex, baked);
}
// Humanoid armor / elytra: drawn even when invisible (vanilla "floating armor").
if (vanilla && s.equipment() != null) addArmorLayers(s, ch, pose, baked);
@@ -370,7 +370,7 @@ public final class CemBaker implements EntityBaker<EntityState> {
// --- vanilla render layers -----------------------------------------------------------------------
private void addVanillaLayers(
EntityState s, Choice ch, Pose pose, Set<String> show, List<CemGeometry.Baked> baked) {
EntityState s, Choice ch, Pose pose, Set<String> show, int[][] bodyTex, List<CemGeometry.Baked> baked) {
String base = ch.base;
boolean baby = ch.babyModel;
for (String n : layerModels(base)) {
@@ -389,6 +389,11 @@ public final class CemBaker implements EntityBaker<EntityState> {
baked.addAll(pose.bake(lm, t, show));
}
case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, baked);
case "SlimeOuterLayer" -> {
// Translucent outer cube on the body texture (alpha < 255 there; blended by the tracer).
int[][] t = lm.texture() != null ? texture(lm.texture()) : bodyTex;
if (t != null) baked.addAll(pose.bake(lm, t, show));
}
case "LlamaDecorLayer", "WolfArmorLayer" -> {
// Body equipment drawn by a dedicated layer: texture folder <base>_body (trader llama: llama_body).
if (s.bodyEquip() == null) continue;
@@ -11,9 +11,10 @@ import org.bukkit.util.Vector;
/**
* Intersects a ray (in block coordinates) with a {@link ResolvedModel}'s element boxes and returns
* the nearest opaque face hit. Axis-aligned boxes use a slab test; rotated elements are handled as
* 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).
* (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 {
@@ -25,6 +26,7 @@ public final class ElementIntersector {
* @param ox,oy,oz ray origin in block-local coordinates (world origin minus block min corner)
* @param dx,dy,dz ray direction (need not be normalized)
* @param bx,by,bz block min corner in world coordinates (for reconstructing the world hit point)
* @param minT only faces strictly beyond this ray parameter count (continuing past a translucent hit)
*/
public static FaceHit intersect(
ResolvedModel model,
@@ -36,7 +38,8 @@ public final class ElementIntersector {
double dz,
int bx,
int by,
int bz) {
int bz,
double minT) {
List<Candidate> candidates = new ArrayList<>(model.elements.size());
for (int i = 0; i < model.elements.size(); i++) {
Element element = model.elements.get(i);
@@ -60,7 +63,7 @@ public final class ElementIntersector {
? intersectAabb(element, ox, oy, oz, dx, dy, dz)
: intersectObb(element, ox, oy, oz, dx, dy, dz);
}
if (c != null) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
if (c != null && c.t > minT) candidates.add(new Candidate(c.element, c.t, c.dir, c.s, c.t2, c.normal, i));
}
if (candidates.isEmpty()) return null;
// Sort by depth; for coplanar faces (equal t) render later elements first, matching vanilla's
@@ -20,8 +20,8 @@ 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, transparency and
* reflection. Contains no Bukkit world access, so it is safe to invoke from worker threads.
* {@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.
*/
public final class SnapshotRaytracer {
@@ -35,6 +35,14 @@ public final class SnapshotRaytracer {
// 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.
private static final int OPAQUE_ALPHA = 250;
private static final int MAX_TRANSLUCENT_LAYERS = 6;
private static final double MIN_TRANSMITTANCE = 0.03;
private static final double LAYER_EPS = 1e-5;
private final BlockModelRegistry registry;
private final BiomeTintProvider tintProvider;
private final SkyRenderer skyRenderer;
@@ -66,41 +74,166 @@ public final class SnapshotRaytracer {
double ox = origin.getX(), oy = origin.getY(), oz = origin.getZ();
double dx = direction.getX(), dy = direction.getY(), dz = direction.getZ();
VoxelDDA dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
int skyColor = skyRenderer.colorFor(direction, origin, sky);
int baseColor = skyColor;
Vector finalPoint = null;
BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth);
boolean entities = scene != null && !scene.isEmpty();
int reflectionColor = 0;
double reflectionFactor = 0;
boolean reflected = false;
// Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1, ray t).
int[] layerColor = null;
double[] layerAlpha = null, layerT = null;
int layers = 0;
double transmittance = 1;
Vector transparencyStart = null;
int transparencyColor = 0;
double transparencyFactor = 0;
int finalColor = skyColor;
double finalT = Double.POSITIVE_INFINITY; // sky
double minT = 0;
BlockHit pending = null; // nearest block surface not consumed yet
BlockData occlusion = null;
for (int i = 0; i < maxSteps && dda.tCurrent <= maxDistance; i++) {
int bx = dda.x, by = dda.y, bz = dda.z;
BlockData data = snapshot.getBlockData(bx, by, bz);
if (data.getMaterial() == Material.AIR) {
occlusion = null;
dda.advance();
continue;
// Walk the surfaces along the ray in depth order (blocks and entities interleaved); translucent ones
// are collected as layers and the ray continues behind them, the first opaque one ends it.
while (true) {
if (pending == null) pending = blocks.next(minT);
double limit = pending != null ? pending.t : maxDistance;
FaceHit eh = entities ? scene.nearestHit(ox, oy, oz, dx, dy, dz, minT, limit) : null;
int color;
double t;
if (eh != null) {
color = ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
t = eh.t();
} else if (pending != null) {
color = pending.color;
t = pending.t;
pending = null;
} else {
break;
}
ResolvedModel model = registry.get(data);
FaceHit hit = ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz);
if (hit == null) {
occlusion = null;
dda.advance();
continue;
int alpha = ColorUtil.alpha(color);
double a = alpha / 255.0;
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;
break;
}
if (layerColor == null) {
layerColor = new int[MAX_TRANSLUCENT_LAYERS];
layerAlpha = new double[MAX_TRANSLUCENT_LAYERS];
layerT = new double[MAX_TRANSLUCENT_LAYERS];
}
layerColor[layers] = color;
layerAlpha[layers] = a;
layerT[layers] = t;
layers++;
transmittance *= 1 - a;
minT = t + LAYER_EPS;
}
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
// Compose back to front: final surface (fogged), the liquid volume where it lies, translucent layers.
int color = Double.isInfinite(finalT) ? finalColor : fog(finalColor, finalT, skyColor);
boolean liquidPending = blocks.liquidT >= 0 && blocks.liquidT < finalT;
for (int i = layers - 1; i >= 0; i--) {
if (liquidPending && layerT[i] < blocks.liquidT) {
color = blocks.applyLiquid(color, finalT, skyColor);
liquidPending = false;
}
color = ColorUtil.mix(fog(layerColor[i], layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]);
}
if (liquidPending) color = blocks.applyLiquid(color, finalT, skyColor);
return color & 0xFFFFFF;
}
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 and ray parameter. */
private record BlockHit(int color, double t) {}
/**
* 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). Liquids are not
* returned as surfaces: the first one is recorded (colour, transparency, reflection) and blended as a
* volume between its surface and the final surface, as before.
*/
private final class BlockCursor {
private final WorldSnapshot snapshot;
private final Vector direction;
private final SkyContext sky;
private final EntityScene scene;
private final int depth;
private final double ox, oy, oz, dx, dy, dz;
private final VoxelDDA dda;
private int steps;
private boolean done;
double liquidT = -1;
private int liquidColor;
private double liquidFactor;
private boolean reflected;
private int reflectionColor;
private double reflectionFactor;
BlockCursor(
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
this.snapshot = snapshot;
this.direction = direction;
this.sky = sky;
this.scene = scene;
this.depth = depth;
this.ox = origin.getX();
this.oy = origin.getY();
this.oz = origin.getZ();
this.dx = direction.getX();
this.dy = direction.getY();
this.dz = direction.getZ();
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
}
/** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */
BlockHit next(double minT) {
while (!done) {
if (steps >= maxSteps || dda.tCurrent > maxDistance) {
done = true;
break;
}
int bx = dda.x, by = dda.y, bz = dda.z;
BlockData data = snapshot.getBlockData(bx, by, bz);
if (data.getMaterial() == Material.AIR) {
advance();
continue;
}
ResolvedModel model = registry.get(data);
FaceHit hit =
ElementIntersector.intersect(model, ox - bx, oy - by, oz - bz, dx, dy, dz, bx, by, bz, minT);
if (hit == null) {
advance();
continue;
}
if (model.transparency > 0) {
recordLiquid(model, hit, data, bx, by, bz);
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
continue;
}
return new BlockHit(color, hit.t());
}
return null;
}
private void advance() {
dda.advance();
steps++;
}
private void recordLiquid(ResolvedModel model, FaceHit hit, BlockData data, int bx, int by, int bz) {
if (!reflected && model.reflection > 0 && depth > 0) {
Vector reflectDir = MathUtil.reflectVector(direction, hit.normal());
Vector reflectStart =
@@ -109,68 +242,48 @@ public final class SnapshotRaytracer {
reflectionFactor = model.reflection;
reflected = true;
}
if (transparencyStart == null && model.transparency > 0) {
transparencyStart = hit.point();
transparencyColor = color;
transparencyFactor = model.transparency;
if (liquidT < 0) {
liquidT = hit.t();
liquidColor = shadeAndTint(hit, data, snapshot, bx, by, bz);
liquidFactor = model.transparency;
}
}
if (model.occluding) {
if (data.equals(occlusion)) {
dda.advance();
continue;
}
occlusion = data;
/** Liquid volume over {@code behind}: denser with depth down to the final surface, plus reflection. */
int applyLiquid(int behind, double finalT, int skyColor) {
double depthIn = Double.isInfinite(finalT) ? 0 : finalT - liquidT;
int surface = fog(liquidColor, liquidT, skyColor);
int c = ColorUtil.mix(behind, surface, liquidFactor, (1 - liquidFactor) * (1 + depthIn / 5.0));
if (reflected) c = ColorUtil.mix(c, reflectionColor, 1 - reflectionFactor, reflectionFactor);
return c;
}
/**
* 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.
*/
private boolean culledByNeighbour(FaceHit hit, BlockData data, int bx, int by, int bz) {
Vector n = hit.normal();
double nx = n.getX(), ny = n.getY(), nz = n.getZ();
int sx = 0, sy = 0, sz = 0;
double local;
if (Math.abs(nx) > 0.99) {
sx = nx > 0 ? 1 : -1;
local = hit.point().getX() - bx;
} else if (Math.abs(ny) > 0.99) {
sy = ny > 0 ? 1 : -1;
local = hit.point().getY() - by;
} else if (Math.abs(nz) > 0.99) {
sz = nz > 0 ? 1 : -1;
local = hit.point().getZ() - bz;
} else {
occlusion = null;
return false;
}
if (transparencyStart != null && model.transparency > 0) {
dda.advance();
continue;
}
baseColor = color;
finalPoint = hit.point();
break;
double boundary = (sx + sy + sz) > 0 ? 1 : 0;
if (Math.abs(local - boundary) > 1e-4) return false;
return snapshot.getBlockData(bx + sx, by + sy, bz + sz).getMaterial() == data.getMaterial();
}
// Entities: if one is closer than the opaque block/sky, it becomes the surface.
if (scene != null && !scene.isEmpty()) {
double blockDist = finalPoint != null ? origin.distance(finalPoint) : maxDistance;
FaceHit eh = scene.nearestHit(ox, oy, oz, dx, dy, dz, blockDist);
if (eh != null) {
baseColor = ColorUtil.shade(eh.color(), shadeFactor(eh.normal()));
finalPoint = eh.point();
reflected = false;
if (transparencyStart != null && origin.distance(transparencyStart) >= eh.t()) {
transparencyStart = null;
}
}
}
if (transparencyStart != null) {
baseColor = ColorUtil.mix(
baseColor,
transparencyColor,
transparencyFactor,
(1 - transparencyFactor)
* (1
+ transparencyStart.distance(finalPoint == null ? transparencyStart : finalPoint)
/ 5.0));
}
if (reflected) {
baseColor = ColorUtil.mix(baseColor, reflectionColor, 1 - reflectionFactor, reflectionFactor);
}
// Distance fog (atmospheric perspective): fade distant geometry toward the sky color.
if (finalPoint != null) {
double fog = fogFactor(origin.distance(finalPoint));
if (fog > 0) baseColor = ColorUtil.mix(baseColor, skyColor, 1 - fog, fog);
}
return baseColor & 0xFFFFFF;
}
private int shadeAndTint(FaceHit hit, BlockData data, WorldSnapshot snapshot, int bx, int by, int bz) {