vanilla fluid rendering: water and lava as fluid polygons with level/neighbour corner heights, flow textures, double-sided and overlay faces, texture alpha, opaque lava, waterlogged blocks and seagrass/kelp/bubble columns hold water, underwater and lava fog from biome water_fog attributes with gaussian biome blend, fogged sky disc, 6/7 fov in fluids, sky light under water also for underwater cameras, fluid test scenes

This commit is contained in:
MineTec committed 2026-11-04 22:07:02 +01:00
1 parent 9093c1ccfb
commit 4348b5fca0
8 files changed
+1160 -175

No files matched your search

@@ -27,11 +27,14 @@ public final class BlockModelRegistry {
private final Map<BlockData, ResolvedModel> cache = new ConcurrentHashMap<>();
private final Map<ResourceLocation, ResolvedModel> modelCache = new ConcurrentHashMap<>();
private volatile ResolvedModel waterModel;
private volatile ResolvedModel lavaModel;
private final AssetReader reader;
/** The texture cache the models are baked from (also used for the fluid sprites). */
public TextureCache textures() {
return textures;
}
/** The asset reader this registry resolves models with (shared by item-model resolution). */
public AssetReader reader() {
return reader;
@@ -62,9 +65,9 @@ public final class BlockModelRegistry {
private ResolvedModel resolve(BlockData data) {
Material material = data.getMaterial();
if (material == Material.WATER) return water();
if (material == Material.LAVA) return lava();
// Technical blocks that are invisible in the world (their textures only show held in hand).
// Technical blocks that are invisible in the world (their textures only show held in hand), and liquid
// blocks (vanilla RenderShape.INVISIBLE: water, lava and bubble columns are drawn by the fluid renderer,
// see render.raytrace.FluidRenderer; block displays of them show nothing, like vanilla).
if (INVISIBLE_MATERIALS.contains(material)) {
return new ResolvedModel(List.of(), 0, 0, 0, false, false);
}
@@ -107,7 +110,7 @@ public final class BlockModelRegistry {
private static EnumSet<Material> buildInvisibleMaterials() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID"}) {
for (String n : new String[] {"BARRIER", "LIGHT", "STRUCTURE_VOID", "WATER", "LAVA", "BUBBLE_COLUMN"}) {
try {
set.add(Material.valueOf(n));
} catch (IllegalArgumentException ignored) {
@@ -169,37 +172,4 @@ public final class BlockModelRegistry {
}
return 0xFF7F7F7F;
}
private ResolvedModel water() {
ResolvedModel m = waterModel;
if (m == null) {
m = liquid("block/water_still", 0, 0.60, 0.10);
waterModel = m;
}
return m;
}
private ResolvedModel lava() {
ResolvedModel m = lavaModel;
if (m == null) {
m = liquid("block/lava_still", -1, 0.15, 0.05);
lavaModel = m;
}
return m;
}
/** Builds a simple full-cube model for a liquid texture with the given tint/transparency/reflection. */
private ResolvedModel liquid(String texturePath, int tintIndex, double transparency, double reflection) {
int[][] tex = textures.get(ResourceLocation.parse(texturePath)).orElse(null);
if (tex == null) {
return new ResolvedModel(List.of(solidCube(0xFF3F76E4)), 0xFF3F76E4, transparency, reflection, true, true);
}
Face[] faces = new Face[6];
for (Direction d : Direction.values()) {
faces[d.ordinal()] = new Face(tex, 0, 0, 1, 1, 0, tintIndex);
}
Element cube = new Element(new double[] {0, 0, 0}, new double[] {1, 1, 1}, faces, null, -1, 0, false);
int avg = AverageColor.of(tex);
return new ResolvedModel(List.of(cube), avg, transparency, reflection, true, true);
}
}
@@ -0,0 +1,133 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.render.snapshot.WorldSnapshot;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeClimate;
import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
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.
*
* <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).
*/
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};
// 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);
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) {
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);
if (fluid != FluidRenderer.WATER) return AIR;
// GaussianSampler.sample(position / 4) over the noise biomes (quart positions).
double px = x * 0.25 - 0.5, py = y * 0.25 - 0.5, pz = z * 0.25 - 0.5;
int ix = (int) Math.floor(px), iy = (int) Math.floor(py), iz = (int) Math.floor(pz);
double rx = px - ix, ry = py - iy, rz = pz - iz;
double r = 0, g = 0, b = 0, start = 0, end = 0, total = 0;
for (int k = 0; k < 6; k++) {
double wz = lerp(rz, KERNEL[k + 1], KERNEL[k]);
for (int i = 0; i < 6; i++) {
double wx = lerp(rx, KERNEL[i + 1], KERNEL[i]);
for (int j = 0; j < 6; j++) {
double wy = lerp(ry, KERNEL[j + 1], KERNEL[j]);
double w = wx * wy * wz;
if (w == 0) continue;
// Quart y clamped into the captured height range (vanilla clamps to the world height).
int by = Math.clamp((iy - 2 + j) * 4, snapshot.minY(), snapshot.maxY() - 1);
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;
start += w * c.waterFogStart();
end += w * c.waterFogEnd();
total += w;
}
}
}
r /= total;
g /= total;
b /= total;
start /= total;
end /= total;
// FogRenderer.computeFogColor: brighten by water vision towards 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;
}
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);
}
private static double lerp(double a, double from, double to) {
return from + a * (to - from);
}
private static int channel(double v) {
return Math.clamp((int) Math.round(v * 255), 0, 255);
}
/** fog.glsl linear_fog_value at distance {@code d}. */
public double fogValue(double d) {
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) {
double f = fogValue(d);
return f > 0 ? ColorUtil.mix(color, fogColor, 1 - f, f) : color & 0xFFFFFF;
}
/**
* 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.
*/
public int sky(int skyColor, double dx, double dy, double dz) {
if (fluid == FluidRenderer.LAVA || dy <= 1e-9) return fogColor;
double horizontal = Math.sqrt(dx * dx + dz * dz);
double radius = DISC_HEIGHT * horizontal / dy;
double lambda = 0;
if (radius > 0) {
double phi = Math.atan2(dz, dx);
double sector = Math.PI / 4;
double rel = phi - Math.floor(phi / sector) * sector; // 0..45°
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 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);
}
}
@@ -0,0 +1,631 @@
package eu.mhsl.minecraft.pixelpics.render.raytrace;
import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Element;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.assets.model.ResolvedModel;
import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockSupport;
import org.bukkit.block.data.BlockData;
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).
*/
public final class FluidRenderer {
public static final int NONE = 0;
public static final int WATER = 1;
public static final int LAVA = 2;
// Vanilla FluidRenderer 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;
/** Block access by world position: a {@code WorldSnapshot} or the live world (main thread). */
@FunctionalInterface
public interface BlockLookup {
BlockData at(int x, int y, int z);
}
/** Blocks whose fluid state is always a water source (LiquidBlockContainer without a waterlogged property). */
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();
private static EnumSet<Material> materials(String... names) {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (String n : names) {
try {
set.add(Material.valueOf(n));
} catch (IllegalArgumentException ignored) {
/* older/newer server */
}
}
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) {
Material m = data.getMaterial();
if (m == Material.AIR) return 0;
if (m == Material.WATER || m == Material.LAVA) {
int type = m == Material.WATER ? WATER : LAVA;
// LiquidBlock: level 0 = source, 1..7 = flowing amount 8 - level, 8..15 = falling amount 8.
int level = data instanceof Levelled l ? l.getLevel() : 0;
if (level == 0) return type | 8 << 2 | 1 << 7;
if (level >= 8) return type | 8 << 2 | 1 << 6;
return type | (8 - level) << 2;
}
if (ALWAYS_WATER.contains(m)) return WATER_SOURCE;
if (data instanceof Waterlogged w && w.isWaterlogged()) return WATER_SOURCE;
return 0;
}
public static int type(int state) {
return state & 3;
}
static int amount(int state) {
return (state >> 2) & 15;
}
static boolean source(int state) {
return (state & 1 << 7) != 0;
}
/** FlowingFluid.getOwnHeight: amount / 9 (0 without fluid). */
static float ownHeight(int state) {
return amount(state) / 9.0F;
}
/**
* 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).
*/
public static int cameraFluid(BlockLookup level, double x, double y, double z) {
int bx = (int) Math.floor(x), by = (int) Math.floor(y), bz = (int) Math.floor(z);
int s = state(level.at(bx, by, bz));
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);
}
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);
}
private static boolean sturdyBottom(BlockData data) {
if (data.getMaterial().isAir()) return false;
try {
return data.isFaceSturdy(BlockFace.DOWN, BlockSupport.FULL);
} catch (RuntimeException e) {
return data.getMaterial().isOccluding();
}
}
// --- 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.
*/
public 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);
if (type(state(d)) == WATER) {
water = i + 1;
continue;
}
Material m = d.getMaterial();
if (m.isAir() || m.isOccluding()) break;
}
return 15 - water;
}
// --- textures ------------------------------------------------------------------------------------------
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).
private final Map<ResolvedModel, Integer> fullFaces = new ConcurrentHashMap<>();
public FluidRenderer(BlockModelRegistry registry, TextureCache textures) {
this.registry = registry;
this.waterStill = texture(textures, "block/water_still", 0xB4B1B1B1);
this.waterFlow = texture(textures, "block/water_flow", 0xB4AAAAAA);
this.waterOverlay = texture(textures, "block/water_overlay", 0xB4A5A5A5);
this.lavaStill = texture(textures, "block/lava_still", 0xFFD9691A);
this.lavaFlow = texture(textures, "block/lava_flow", 0xFFCF5C15);
}
private static int[][] texture(TextureCache textures, String path, int fallback) {
int[][] tex = textures.get(ResourceLocation.parse(path)).orElse(null);
return tex != null && tex.length > 0 && tex[0].length > 0 ? tex : new int[][] {{fallback}};
}
/** Samples a sprite at (u, v) in 0..1 of the sprite (vanilla getU/getV), nearest texel. */
private static int sample(int[][] tex, double u, double v) {
int h = tex.length, w = tex[0].length;
int x = Math.clamp((int) Math.floor(u * w), 0, w - 1);
int y = Math.clamp((int) Math.floor(v * h), 0, h - 1);
return tex[y][x];
}
// --- face occlusion ------------------------------------------------------------------------------------
private int fullFaces(BlockData data) {
Material m = data.getMaterial();
if (m.isAir()) return 0;
return fullFaces.computeIfAbsent(registry.get(data), FluidRenderer::computeFullFaces);
}
/** Faces fully covered by opaque, unrotated element faces on the cell boundary (8x8 sample grid). */
private static int computeFullFaces(ResolvedModel model) {
int mask = 0;
if (model.elements.isEmpty()) return 0;
for (Direction d : Direction.values()) {
int axis = d.nx != 0 ? 0 : d.ny != 0 ? 1 : 2;
int a1 = axis == 0 ? 1 : 0, a2 = axis == 2 ? 1 : 2;
boolean positive = d.nx + d.ny + d.nz > 0;
boolean covered = true;
for (int i = 0; i < 8 && covered; i++) {
for (int j = 0; j < 8 && covered; j++) {
double p1 = (i + 0.5) / 8, p2 = (j + 0.5) / 8;
boolean any = false;
for (Element e : model.elements) {
if (e.rotAxis != -1) continue;
Face f = e.faces[d.ordinal()];
if (f == null || !opaqueTexture(f)) continue;
double bound = positive ? e.to[axis] : e.from[axis];
if (Math.abs(bound - (positive ? 1 : 0)) > 1e-6) continue;
if (p1 < Math.min(e.from[a1], e.to[a1]) || p1 > Math.max(e.from[a1], e.to[a1])) continue;
if (p2 < Math.min(e.from[a2], e.to[a2]) || p2 > Math.max(e.from[a2], e.to[a2])) continue;
any = true;
break;
}
covered = any;
}
}
if (covered) mask |= 1 << d.ordinal();
}
return mask;
}
private static boolean opaqueTexture(Face f) {
if (f.texture == null) return false;
for (int[] row : f.texture) for (int px : row) if ((px >>> 24) != 0xFF) return false;
return true;
}
/** FluidRenderer.isFaceOccludedByState for a neighbour/self block whose face points at the fluid. */
private boolean occludes(BlockData block, Direction face) {
return (fullFaces(block) & 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. */
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);
}
return !data.getMaterial().isSolid() ? 0.0F : -1.0F;
}
private static float height(BlockLookup level, int type, int x, int y, int z) {
BlockData d = level.at(x, y, z);
return height(level, type, x, y, z, d, state(d));
}
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];
if (h1 > 0.0F || h2 > 0.0F) {
float 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];
}
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]++;
}
}
// --- flow (top texture direction) ---------------------------------------------------------------------
/**
* 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.
*/
private static double[] flow(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) {
BlockData nd = level.at(x + d[0], y, z + d[1]);
int ns = state(nd);
if (!affectsFlow(type, ns)) continue;
float nh = ownHeight(ns);
float distance = 0.0F;
if (nh == 0.0F) {
if (!blocksFluidFlow(nd)) {
int below = state(level.at(x + d[0], y - 1, z + d[1]));
if (affectsFlow(type, below)) {
nh = ownHeight(below);
if (nh > 0.0F) distance = own - (nh - 0.8888889F);
}
}
} else if (nh > 0.0F) {
distance = own - nh;
}
if (distance != 0.0F) {
fx += d[0] * distance;
fz += d[1] * distance;
}
}
// 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};
}
private static boolean affectsFlow(int type, int neighbourState) {
int t = type(neighbourState);
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");
}
/** FluidState.shouldRenderBackwardUpFace: some block of the 3x3 above is neither the fluid nor solid-render. */
private static boolean backwardUpFace(BlockLookup level, int type, int x, int y, int z) {
for (int ox = -1; ox <= 1; ox++) {
for (int oz = -1; oz <= 1; oz++) {
BlockData d = level.at(x + ox, y, z + oz);
if (type(state(d)) != type && !d.getMaterial().isOccluding()) return true;
}
}
return false;
}
// --- 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
}
/**
* 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.
*/
public Crossing nearest(
BlockLookup level,
int bx,
int by,
int bz,
BlockData data,
int state,
double ox,
double oy,
double oz,
double dx,
double dy,
double dz,
double minT) {
int type = type(state);
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;
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
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);
if (!(renderUp || renderDown || renderN || renderS || renderW || renderE)) return null;
float self = height(level, type, bx, by, bz, data, state);
float hNE, hNW, hSE, hSW;
if (self >= 1.0F) {
hNE = hNW = hSE = hSW = 1.0F;
} else {
float hN = height(level, type, bx, by, bz - 1, north, sNorth);
float hS = height(level, type, bx, by, bz + 1, south, sSouth);
float hE = height(level, type, bx + 1, by, bz, east, sEast);
float hW = height(level, type, bx - 1, by, bz, west, sWest);
hNE = averageHeight(level, type, self, hN, hE, bx + 1, by, bz - 1);
hNW = averageHeight(level, type, self, hN, hW, bx - 1, by, bz - 1);
hSE = averageHeight(level, type, self, hS, hE, bx + 1, by, bz + 1);
hSW = averageHeight(level, type, self, hS, hW, bx - 1, by, bz + 1);
}
float bottom = renderDown ? OFFS : 0.0F;
// The top is not drawn under a block covering it when every corner is full.
boolean drawUp =
renderUp && !(Math.min(Math.min(hNW, hSW), Math.min(hSE, hNE)) >= 1.0F && occludes(up, Direction.DOWN));
if (drawUp) {
hNW -= OFFS;
hSW -= OFFS;
hSE -= OFFS;
hNE -= OFFS;
}
double lx = ox - bx, ly = oy - by, lz = oz - bz;
double bestT = Double.POSITIVE_INFINITY;
int bestFace = -1; // 0 top, 1 bottom, 2 N, 3 S, 4 W, 5 E
double bestNx = 0, bestNy = 0, bestNz = 0;
boolean bestEntering = false;
double hitX = 0, hitY = 0, hitZ = 0;
boolean sideOverlay = false;
double sideS = 0;
if (drawUp) {
Boolean backUp = null;
// Triangle 1: NW(0,0) SW(0,1) SE(1,1) (z >= x); triangle 2: NW, SE, NE(1,0) (z < x).
for (int tri = 0; tri < 2; tri++) {
double a = hNW, b, c;
if (tri == 0) {
b = hSE - hSW;
c = hSW - hNW;
} else {
b = hNE - hNW;
c = hSE - hNE;
}
// Plane y = a + b·x + c·z, outward normal (-b, 1, -c).
double denom = dy - b * dx - c * dz;
if (Math.abs(denom) < 1e-12) continue;
double t = (a + b * lx + c * lz - ly) / denom;
if (!(t > minT) || t >= bestT) continue;
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
if (!entering) {
if (backUp == null) backUp = backwardUpFace(level, type, bx, by + 1, bz);
if (!backUp) continue;
}
double len = Math.sqrt(b * b + 1 + c * c);
bestT = t;
bestFace = 0;
bestNx = -b / len;
bestNy = 1 / len;
bestNz = -c / len;
bestEntering = entering;
hitX = x;
hitY = ly + dy * t;
hitZ = z;
}
}
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;
if (x >= -EPS && x <= 1 + EPS && z >= -EPS && z <= 1 + EPS) {
bestT = t;
bestFace = 1;
bestNx = 0;
bestNy = -1;
bestNz = 0;
bestEntering = true;
hitX = x;
hitY = bottom;
hitZ = z;
}
}
}
// Side faces: plane, outward normal, edge heights from vertex 0 to vertex 1 (vanilla order) along s.
for (int face = 2; face <= 5; face++) {
boolean render;
BlockData neighbour;
double plane;
float h0, h1;
switch (face) {
case 2 -> {
render = renderN;
neighbour = north;
plane = OFFS;
h0 = hNW;
h1 = hNE;
}
case 3 -> {
render = renderS;
neighbour = south;
plane = 1 - OFFS;
h0 = hSE;
h1 = hSW;
}
case 4 -> {
render = renderW;
neighbour = west;
plane = OFFS;
h0 = hSW;
h1 = hNW;
}
default -> {
render = renderE;
neighbour = east;
plane = 1 - OFFS;
h0 = hNE;
h1 = hSE;
}
}
if (!render) continue;
boolean alongX = face == 2 || face == 3;
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;
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;
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 top = h0 + (h1 - h0) * s;
if (y < bottom - EPS || y > top + EPS) continue;
bestT = t;
bestFace = face;
bestNx = alongX ? 0 : outward;
bestNy = 0;
bestNz = alongX ? outward : 0;
bestEntering = entering;
hitX = x;
hitY = y;
hitZ = z;
sideOverlay = overlay;
sideS = s;
}
if (bestFace < 0) return null;
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;
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;
}
}
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;
};
}
}
@@ -11,7 +11,6 @@ import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider;
import eu.mhsl.minecraft.pixelpics.render.tint.TintResolver;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import eu.mhsl.minecraft.pixelpics.render.util.MathUtil;
import java.util.EnumSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.bukkit.Material;
@@ -46,11 +45,14 @@ public final class SnapshotRaytracer {
// layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind.
private static final double ADDITIVE = -1;
// Brightness of a surface under water seen from above, by sky light level (index): vanilla lightmap
// (lightmap.fsh: level / (4 - 3·level), mixed halfway with notGamma = default brightness option 0.5, overworld
// ambient 0). Sky light loses one level per water block above the face (water's light block = 1), so the floor
// gets darker with VERTICAL depth; the path length through the water plays no role in vanilla when the camera
// is above the surface (no water fog there): one translucent surface layer over the dimmed scene.
// 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).
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.
private static final double[] SKY_LIGHT = new double[16];
static {
@@ -62,28 +64,8 @@ public final class SnapshotRaytracer {
}
}
// Vanilla HalfTransparentBlock / LeavesBlock that do not occlude: water renders its faces against them with the
// water overlay texture (FluidRenderer). Blue ice is a HalfTransparentBlock too but occludes, packed ice is not.
private static final EnumSet<Material> WATER_OVERLAY_NEIGHBOURS = buildWaterOverlayNeighbours();
private static EnumSet<Material> buildWaterOverlayNeighbours() {
EnumSet<Material> set = EnumSet.noneOf(Material.class);
for (Material m : Material.values()) {
String n = m.name();
if (m.isLegacy()) continue;
if (n.equals("GLASS")
|| n.equals("TINTED_GLASS")
|| n.endsWith("_STAINED_GLASS")
|| n.equals("ICE")
|| n.equals("FROSTED_ICE")
|| n.equals("SLIME_BLOCK")
|| n.equals("HONEY_BLOCK")
|| n.endsWith("_LEAVES")) set.add(m);
}
return set;
}
private final BlockModelRegistry registry;
private final FluidRenderer fluids;
private final BiomeTintProvider tintProvider;
private final SkyRenderer skyRenderer;
private final double maxDistance;
@@ -98,6 +80,7 @@ public final class SnapshotRaytracer {
double maxDistance,
int reflectionDepth) {
this.registry = registry;
this.fluids = new FluidRenderer(registry, registry.textures());
this.tintProvider = tintProvider;
this.skyRenderer = skyRenderer;
this.maxDistance = maxDistance;
@@ -105,33 +88,57 @@ 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}). */
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, reflectionDepth);
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,
Vector direction,
SkyContext sky,
EntityScene scene,
CameraMedium medium) {
return trace(snapshot, origin, direction, sky, scene, reflectionDepth, medium);
}
private int trace(
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
WorldSnapshot snapshot,
Vector origin,
Vector direction,
SkyContext sky,
EntityScene scene,
int depth,
CameraMedium medium) {
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);
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).
// ray t, already lit: fluid faces carry their own sky light).
int[] layerColor = null;
double[] layerAlpha = null, layerT = null;
boolean[] layerLit = null;
int layers = 0;
double transmittance = 1;
int finalColor = skyColor;
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 and entities interleaved); translucent ones
// are collected as layers and the ray continues behind them, the first opaque one ends it.
// 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.
while (true) {
if (pending == null) pending = blocks.next(minT);
double limit = pending != null ? pending.t : maxDistance;
@@ -139,6 +146,7 @@ public final class SnapshotRaytracer {
int color;
double t;
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).
@@ -149,6 +157,7 @@ public final class SnapshotRaytracer {
} else if (pending != null) {
color = pending.color;
t = pending.t;
lit = pending.lit;
pending = null;
} else {
break;
@@ -168,58 +177,69 @@ public final class SnapshotRaytracer {
// Opaque, or what lies behind would contribute next to nothing: this surface ends the ray.
finalColor = color;
finalT = t;
finalLit = lit;
break;
}
if (layerColor == null) {
layerColor = new int[MAX_TRANSLUCENT_LAYERS];
layerAlpha = new double[MAX_TRANSLUCENT_LAYERS];
layerT = new double[MAX_TRANSLUCENT_LAYERS];
layerLit = new boolean[MAX_TRANSLUCENT_LAYERS];
}
layerColor[layers] = color;
layerAlpha[layers] = additive ? ADDITIVE : a;
layerT[layers] = t;
layerLit[layers] = lit;
layers++;
if (!additive) transmittance *= 1 - a;
minT = t + LAYER_EPS;
}
// Compose back to front: final surface (fogged), the liquid volume where it lies, translucent layers.
// Surfaces under water (seen from above it) are dimmed by their depth like vanilla sky light.
int color = Double.isInfinite(finalT)
? finalColor
: fog(blocks.underwaterLight(finalColor, finalT), finalT, skyColor);
boolean liquidPending = blocks.liquidT >= 0 && blocks.liquidT < finalT;
// 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.
int color;
if (Double.isInfinite(finalT)) {
color = medium.inFluid() ? medium.sky(finalColor, dx, dy, dz) : finalColor;
} else {
int lit = finalLit ? finalColor : blocks.underwaterLight(finalColor, finalT);
color = fog(lit, finalT, skyColor, medium);
}
for (int i = layers - 1; i >= 0; i--) {
if (liquidPending && layerT[i] < blocks.liquidT) {
color = blocks.applyLiquid(color, finalT, skyColor);
liquidPending = false;
}
if (layerAlpha[i] == ADDITIVE) {
// Added light fades with distance like the surface it would otherwise be.
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - fogFactor(layerT[i])));
double f = medium.inFluid() ? medium.fogValue(layerT[i]) : fogFactor(layerT[i]);
color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - f));
} else {
int layer = blocks.underwaterLight(layerColor[i], layerT[i]);
color = ColorUtil.mix(fog(layer, layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]);
int layer = layerLit[i] ? layerColor[i] : blocks.underwaterLight(layerColor[i], layerT[i]);
color = ColorUtil.mix(fog(layer, layerT[i], skyColor, medium), color, layerAlpha[i], 1 - layerAlpha[i]);
}
}
if (liquidPending) color = blocks.applyLiquid(color, finalT, skyColor);
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 and ray parameter. */
private record BlockHit(int color, double t) {}
/**
* A block surface found by the {@link BlockCursor}: shaded/tinted ARGB colour, ray parameter and whether it is
* already lit (fluid faces carry their own sky light).
*/
private record BlockHit(int color, double t, boolean lit) {}
/**
* 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.
* 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.
*/
private final class BlockCursor {
private final WorldSnapshot snapshot;
@@ -227,28 +247,30 @@ public final class SnapshotRaytracer {
private final SkyContext sky;
private final EntityScene scene;
private final int depth;
private final CameraMedium medium;
private final double ox, oy, oz, dx, dy, dz;
private final VoxelDDA dda;
private int steps;
private boolean done;
double liquidT = -1;
// The ray starts inside a liquid (underwater photo): the liquid acts as a fog along the path instead.
private final boolean startsInLiquid;
private Material left; // material of the voxel the ray passed through last
private int liquidColor;
private double liquidFactor;
// The ray met water (a water cell, or the camera is in water): surfaces may need the underwater light.
private boolean sawWater;
private boolean reflected;
private int reflectionColor;
private double reflectionFactor;
BlockCursor(
WorldSnapshot snapshot, Vector origin, Vector direction, SkyContext sky, EntityScene scene, int depth) {
WorldSnapshot snapshot,
Vector origin,
Vector direction,
SkyContext sky,
EntityScene scene,
int depth,
CameraMedium medium) {
this.snapshot = snapshot;
this.direction = direction;
this.sky = sky;
this.scene = scene;
this.depth = depth;
this.medium = medium;
this.ox = origin.getX();
this.oy = origin.getY();
this.oz = origin.getZ();
@@ -256,8 +278,7 @@ public final class SnapshotRaytracer {
this.dy = direction.getY();
this.dz = direction.getZ();
this.dda = new VoxelDDA(ox, oy, oz, dx, dy, dz);
BlockData start = snapshot.getBlockData((int) Math.floor(ox), (int) Math.floor(oy), (int) Math.floor(oz));
this.startsInLiquid = start.getMaterial() != Material.AIR && registry.get(start).transparency > 0;
this.sawWater = medium.fluid() == FluidRenderer.WATER;
}
/** The next block surface with {@code t > minT}, or null when the ray leaves the render distance. */
@@ -271,29 +292,21 @@ public final class SnapshotRaytracer {
BlockData data = snapshot.getBlockData(bx, by, bz);
Material material = data.getMaterial();
if (material == Material.AIR) {
advance(material);
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(material);
continue;
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);
}
if (model.transparency > 0) {
if (liquidT >= 0 && material == Material.WATER && WATER_OVERLAY_NEIGHBOURS.contains(left)) {
// Under water, leaving glass/ice/slime/honey/leaves into water again: vanilla FluidRenderer
// draws the water's faces against such non-occluding blocks (side faces with the water
// overlay texture), facing into the block, so from inside it the far wall is a water layer.
int c = shadeAndTint(hit, data, snapshot, bx, by, bz);
int a = (int) Math.round((1 - model.transparency) * 255);
advance(material);
return new BlockHit(
ColorUtil.argb(a, ColorUtil.red(c), ColorUtil.green(c), ColorUtil.blue(c)), hit.t());
}
recordLiquid(model, hit, data, bx, by, bz);
advance(material);
if (hit == null) {
advance();
continue;
}
int color = shadeAndTint(hit, data, snapshot, bx, by, bz);
@@ -301,76 +314,69 @@ public final class SnapshotRaytracer {
minT = hit.t(); // inner face between two blocks of the same translucent kind: not drawn
continue;
}
return new BlockHit(color, hit.t());
return new BlockHit(color, hit.t(), false);
}
return null;
}
/** Steps to the next voxel; {@code material} is the one of the voxel being left. */
private void advance(Material material) {
left = material;
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 =
hit.point().clone().add(hit.normal().clone().multiply(1e-3));
reflectionColor = trace(snapshot, reflectStart, reflectDir, sky, scene, depth - 1);
reflectionFactor = model.reflection;
reflected = true;
}
if (liquidT < 0) {
liquidT = hit.t();
liquidColor = shadeAndTint(hit, data, snapshot, bx, by, bz);
liquidFactor = model.transparency;
}
}
/**
* The liquid over {@code behind} (everything beyond its surface, already dimmed by
* {@link #underwaterLight}): seen from above, one translucent surface layer like vanilla, independent of
* the path length through the liquid (before, the surface weight grew with the path, so blocks a few
* blocks deep and anything seen at a shallow angle vanished in the water colour). From inside the liquid
* the path-length model stays (a fog). Plus reflection.
* 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).
*/
int applyLiquid(int behind, double finalT, int skyColor) {
int surface = fog(liquidColor, liquidT, skyColor);
double surfaceWeight = 1 - liquidFactor;
if (startsInLiquid) {
double depthIn = Double.isInfinite(finalT) ? 0 : finalT - liquidT;
surfaceWeight *= 1 + depthIn / 5.0;
private BlockHit fluidHit(FluidRenderer.Crossing c, int bx, int by, int bz) {
int color = c.texel;
boolean water = c.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 level = Math.max(
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY, c.lightZ),
FluidRenderer.skyLevel(snapshot::getBlockData, c.lightX, c.lightY + 1, c.lightZ));
if (level < 15) light = SKY_LIGHT[level];
}
int c = ColorUtil.mix(behind, surface, liquidFactor, surfaceWeight);
if (reflected) c = ColorUtil.mix(c, reflectionColor, 1 - reflectionFactor, reflectionFactor);
return c;
color = ColorUtil.shade(color, c.shade * 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;
Vector normal = new Vector(c.nx, c.ny, c.nz);
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).
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;
int mixed = ColorUtil.mix(color, reflection, wl, wr);
color = ColorUtil.argb(
(int) Math.round(a2 * 255),
ColorUtil.red(mixed),
ColorUtil.green(mixed),
ColorUtil.blue(mixed));
}
return new BlockHit(color, c.t, true);
}
/**
* {@code color} of a surface at ray parameter {@code t} dimmed like vanilla sky light under water, when it
* lies beyond the water surface and the ray started above it: level 15 minus the blocks from the block in
* front of the surface up to the topmost water block of that column (water, and non-occluding blocks below
* it such as glass, cost one level each). Unchanged without water on the ray.
* {@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.
*/
int underwaterLight(int color, double t) {
if (liquidT < 0 || startsInLiquid || !(t > liquidT) || Double.isInfinite(t)) return color;
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.
double back = 1e-4 / Math.sqrt(dx * dx + dy * dy + dz * dz);
int bx = (int) Math.floor(ox + dx * (t - back));
int by = (int) Math.floor(oy + dy * (t - back));
int bz = (int) Math.floor(oz + dz * (t - back));
int water = 0;
for (int i = 0; i < 15; i++) {
Material m = snapshot.getBlockData(bx, by + i, bz).getMaterial();
if (m == Material.WATER) {
water = i + 1;
} else if (m == Material.AIR || m == Material.CAVE_AIR || m == Material.VOID_AIR || m.isOccluding()) {
break;
}
}
return water == 0 ? color : ColorUtil.shade(color, SKY_LIGHT[15 - water]);
int level = FluidRenderer.skyLevel(snapshot::getBlockData, bx, by, bz);
return level >= 15 ? color : ColorUtil.shade(color, SKY_LIGHT[level]);
}
/**
@@ -12,6 +12,8 @@ import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker;
import eu.mhsl.minecraft.pixelpics.render.entity.cem.ItemGeometry;
import eu.mhsl.minecraft.pixelpics.render.raytrace.CameraMedium;
import eu.mhsl.minecraft.pixelpics.render.raytrace.FluidRenderer;
import eu.mhsl.minecraft.pixelpics.render.raytrace.SnapshotRaytracer;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyContext;
import eu.mhsl.minecraft.pixelpics.render.sky.SkyRenderer;
@@ -50,6 +52,8 @@ public class DefaultScreenRenderer implements Renderer {
* horizontal FOV follows from the image aspect ratio. Square photos: 70° both ways.
*/
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;
private static final double MAX_DISTANCE = 256;
private static final int REFLECTION_DEPTH = 4;
@@ -115,7 +119,14 @@ public class DefaultScreenRenderer implements Renderer {
public RenderJob prepare(Location eyeLocation, Resolution resolution, UUID shooter) {
int superW = resolution.getWidth() * SSAA;
int superH = resolution.getHeight() * SSAA;
List<Vector> rayMap = buildRayMap(eyeLocation, superW, superH);
// 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;
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);
Collection<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE);
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
@@ -170,9 +181,10 @@ public class DefaultScreenRenderer implements Renderer {
job.objects(),
objectBaker);
CameraMedium medium = raytracer.cameraMedium(snapshot, origin);
int[] superBuf = new int[rayMap.size()];
runParallel(() -> IntStream.range(0, rayMap.size()).parallel().forEach(i -> {
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene);
if (!cancelled.get()) superBuf[i] = raytracer.trace(snapshot, origin, rayMap.get(i), sky, scene, medium);
}));
BufferedImage image = new BufferedImage(finalW, finalH, BufferedImage.TYPE_INT_RGB);
@@ -216,13 +228,18 @@ public class DefaultScreenRenderer implements Renderer {
* from the yaw/pitch of {@code eye} (no roll), so looking straight up or down keeps the yaw orientation.
*/
public static List<Vector> buildRayMap(Location eye, int width, int height) {
return buildRayMap(eye, width, height, V_FOV_DEG);
}
/** {@link #buildRayMap(Location, int, int)} with the vertical FOV {@code vFovDeg}. */
public static List<Vector> buildRayMap(Location eye, int width, int height, double vFovDeg) {
double yaw = Math.toRadians(eye.getYaw());
Vector forward = eye.getDirection();
// Player's right in Minecraft's coordinates (yaw 0 = south/+Z → right = west/−X).
Vector right = new Vector(-Math.cos(yaw), 0, -Math.sin(yaw));
Vector up = right.getCrossProduct(forward).normalize();
double tanY = Math.tan(Math.toRadians(V_FOV_DEG) / 2);
double tanY = Math.tan(Math.toRadians(vFovDeg) / 2);
double tanX = tanY * width / height;
List<Vector> rayMap = new ArrayList<>(width * height);
@@ -31,6 +31,9 @@ public final class BiomeClimate {
* @param foliage explicit foliage color override, or {@link #NONE}
* @param dryFoliage explicit dry-foliage color override, or {@link #NONE}
* @param grassModifier vanilla {@code grass_color_modifier} ("swamp", "dark_forest") or null
* @param waterFogColor RGB underwater fog colour (attribute {@code minecraft:visual/water_fog_color})
* @param waterFogStart underwater fog start distance ({@code minecraft:visual/water_fog_start_distance})
* @param waterFogEnd underwater fog end distance ({@code minecraft:visual/water_fog_end_distance})
*/
public record Climate(
double temperature,
@@ -39,9 +42,52 @@ public final class BiomeClimate {
int grass,
int foliage,
int dryFoliage,
String grassModifier) {}
String grassModifier,
int waterFogColor,
double waterFogStart,
double waterFogEnd) {
Climate(
double temperature,
double downfall,
int water,
int grass,
int foliage,
int dryFoliage,
String grassModifier) {
this(
temperature,
downfall,
water,
grass,
foliage,
dryFoliage,
grassModifier,
DEFAULT_WATER_FOG_COLOR,
DEFAULT_WATER_FOG_START,
DEFAULT_WATER_FOG_END);
}
Climate withWaterFog(int color, double endFactor) {
return new Climate(
temperature,
downfall,
water,
grass,
foliage,
dryFoliage,
grassModifier,
color,
waterFogStart,
waterFogEnd * endFactor);
}
}
public static final int DEFAULT_WATER = 0x3F76E4;
// Vanilla 26.3 EnvironmentAttributes defaults (no dimension type sets them).
public static final int DEFAULT_WATER_FOG_COLOR = 0x050533;
public static final double DEFAULT_WATER_FOG_START = -8;
public static final double DEFAULT_WATER_FOG_END = 96;
public static final Climate DEFAULT = new Climate(0.8, 0.4, DEFAULT_WATER, NONE, NONE, NONE, null);
private static final String DATA_PATH = "data/minecraft/worldgen/biome/";
@@ -123,6 +169,20 @@ public final class BiomeClimate {
put("windswept_hills", 0.2, 0.3, 0x3F76E4, NONE, NONE, NONE, null);
put("windswept_savanna", 2.0, 0.0, 0x3F76E4, NONE, NONE, NONE, null);
put("wooded_badlands", 2.0, 0.0, 0x3F76E4, 0x90814D, 0x9E814D, NONE, null);
// Biome attributes minecraft:visual/water_fog_color (+ water_fog_end_distance multiply modifier).
fog("cherry_grove", 0x5DB7EF, 1);
fog("dappled_forest", 0x375154, 1);
fog("deep_lukewarm_ocean", 0x041633, 1);
fog("lukewarm_ocean", 0x041633, 1);
fog("mangrove_swamp", 0x4D7A60, 0.85);
fog("pale_garden", 0x556980, 1);
fog("sulfur_caves", 0x17543C, 1);
fog("swamp", 0x232317, 0.85);
fog("warm_ocean", 0x041F33, 1);
}
private static void fog(String key, int color, double endFactor) {
FALLBACK.put(key, FALLBACK.get(key).withWaterFog(color, endFactor));
}
private BiomeClimate() {}
@@ -142,6 +202,7 @@ public final class BiomeClimate {
.getAsJsonObject();
JsonObject effects = root.has("effects") ? root.getAsJsonObject("effects") : new JsonObject();
JsonElement modifier = effects.get("grass_color_modifier");
JsonObject attributes = root.has("attributes") ? root.getAsJsonObject("attributes") : new JsonObject();
return Optional.of(new Climate(
root.has("temperature") ? root.get("temperature").getAsDouble() : DEFAULT.temperature(),
root.has("downfall") ? root.get("downfall").getAsDouble() : DEFAULT.downfall(),
@@ -149,12 +210,42 @@ public final class BiomeClimate {
color(effects, "grass_color", NONE),
color(effects, "foliage_color", NONE),
color(effects, "dry_foliage_color", NONE),
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null));
modifier != null && modifier.isJsonPrimitive() ? modifier.getAsString() : null,
color(attributes, "minecraft:visual/water_fog_color", DEFAULT_WATER_FOG_COLOR),
floatAttribute(attributes, "minecraft:visual/water_fog_start_distance", DEFAULT_WATER_FOG_START),
floatAttribute(attributes, "minecraft:visual/water_fog_end_distance", DEFAULT_WATER_FOG_END)));
} catch (Exception e) {
return Optional.empty();
}
}
/**
* A float environment attribute of a biome applied to the default: a plain value overrides it, an object
* {@code {"argument": a, "modifier": op}} applies vanilla's float modifier (override/add/subtract/multiply/
* minimum/maximum).
*/
private static double floatAttribute(JsonObject attributes, String key, double base) {
JsonElement el = attributes.get(key);
if (el == null) return base;
try {
if (el.isJsonPrimitive()) return el.getAsDouble();
if (!el.isJsonObject()) return base;
JsonObject o = el.getAsJsonObject();
double a = o.get("argument").getAsDouble();
String op = o.has("modifier") ? o.get("modifier").getAsString() : "override";
return switch (op) {
case "add" -> base + a;
case "subtract" -> base - a;
case "multiply" -> base * a;
case "minimum" -> Math.min(base, a);
case "maximum" -> Math.max(base, a);
default -> a;
};
} catch (RuntimeException e) {
return base;
}
}
/** Colors are either "#rrggbb" strings or packed integers, depending on the data version. */
private static int color(JsonObject effects, String key, int fallback) {
JsonElement el = effects.get(key);
@@ -29,6 +29,11 @@ public final class BiomeTintProvider {
return cache.computeIfAbsent(keyOf(biome), this::compute);
}
/** The biome's climate/effects/attribute data (water fog etc.), see {@link BiomeClimate}. */
public BiomeClimate.Climate climate(Biome biome) {
return BiomeClimate.forKey(keyOf(biome));
}
private String keyOf(Biome biome) {
try {
return biome.getKey().getKey();
+134 -2
View File
@@ -12,6 +12,7 @@ import javax.imageio.ImageIO;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.*;
import org.bukkit.block.Biome;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.BlockData;
@@ -212,6 +213,30 @@ public class PPTest extends JavaPlugin {
scenes.put("items_inverted", new Scene("items_inverted", this::itemsInverted, 3, 2.4, 7, 3, 0.6, 1.5));
scenes.put("xmas_chests", new Scene("xmas_chests", this::xmasChests, 6, 3.6, 9.5, 6, 0.5, 1.5));
scenes.put("frames_markers", new Scene("frames_markers", this::framesMarkers, 2.5, 2.4, 4.2, 2.5, 2.4, 0));
// Fluids (vanilla FluidRenderer / FogRenderer): liq_flow = water flowing with physics (rendered 200 ticks after
// building): a source on a 3-high terrace spreading and falling over its edges, a lone source spreading on
// the floor, a sunk 3x3 source pool (surface 8/9 below the rim). liq_logged = waterlogged blocks in a 1-deep
// pool, standing in the air (water inside, surface at 8/9) and under water. liq_under* = camera in a
// 22x22x8 tank with concrete pillars at growing distances (water fog), seen from 3 and 7 deep, looking up at
// the surface (bridge + lily pads above), just under the surface, from above; _swamp/_warm = same tank in a
// swamp / warm ocean (water_fog_color, swamp end x0.85, water_color). liq_lava = lava pool, flowing levels
// and a falling column; liq_lava_in = camera inside the lava (lava fog).
scenes.put("liq_flow", new Scene("liq_flow", this::liqFlow, -3, 8, -9, 6, 0, 0));
scenes.put("liq_flow_close", new Scene("liq_flow_close", this::liqFlow, 7.5, 1.6, 9, 11, 0, 5));
scenes.put("liq_flow_fall", new Scene("liq_flow_fall", this::liqFlow, -4, 3, 7, 2, 1.5, 0));
scenes.put("liq_logged", new Scene("liq_logged", this::liqLogged, 6, 3.6, -4.5, 6, -0.5, 3.5));
scenes.put("liq_logged_close", new Scene("liq_logged_close", this::liqLogged, 2.2, 0.7, -1.4, 3, -0.4, 1.5));
scenes.put("liq_logged_air", new Scene("liq_logged_air", this::liqLogged, 4, 1.6, 2.6, 4.5, 0.4, 5.5));
scenes.put("liq_under", new Scene("liq_under", o -> liqUnder(o, Biome.PLAINS), -3.5, -3, -8.5, 12, -5, 6));
scenes.put("liq_under_deep", new Scene("liq_under_deep", o -> liqUnder(o, Biome.PLAINS), -3.5, -7, -8.5, 12, -6, 6));
scenes.put("liq_under_up", new Scene("liq_under_up", o -> liqUnder(o, Biome.PLAINS), 6.2, -6, -4, 6.4, 0, 0.5));
scenes.put("liq_under_shallow", new Scene("liq_under_shallow", o -> liqUnder(o, Biome.PLAINS), -3.5, -0.45, -8.5, 12, 0.6, 6));
scenes.put("liq_under_top", new Scene("liq_under_top", o -> liqUnder(o, Biome.PLAINS), -3, 4, -10, 6, -6, 2));
scenes.put("liq_under_swamp", new Scene("liq_under_swamp", o -> liqUnder(o, Biome.SWAMP), -3.5, -3, -8.5, 12, -5, 6));
scenes.put("liq_under_warm", new Scene("liq_under_warm", o -> liqUnder(o, Biome.WARM_OCEAN), -3.5, -3, -8.5, 12, -5, 6));
scenes.put("liq_under_warm_top", new Scene("liq_under_warm_top", o -> liqUnder(o, Biome.WARM_OCEAN), -3, 4, -10, 6, -6, 2));
scenes.put("liq_lava", new Scene("liq_lava", this::liqLava, 4, 4.5, -6, 9, 0, 1));
scenes.put("liq_lava_in", new Scene("liq_lava_in", this::liqLava, 3.5, -0.5, 1.5, 9, -0.5, 1.5));
}
private static ItemStack shield(DyeColor base, Object... patterns) {
@@ -838,7 +863,7 @@ public class PPTest extends JavaPlugin {
t.printStackTrace();
}
// Let one tick pass (passengers/leashes settle), then render.
Bukkit.getScheduler().runTaskLater(this, () -> render(sc, o), sc.name.equals("proj_shake") ? 4 : sc.name.startsWith("proj_") ? 30 : sc.name.startsWith("be_contents") ? 100 : 10);
Bukkit.getScheduler().runTaskLater(this, () -> render(sc, o), sc.name.equals("proj_shake") ? 4 : sc.name.startsWith("proj_") ? 30 : sc.name.startsWith("be_contents") ? 100 : sc.name.startsWith("liq_flow") ? 200 : 10);
}
private void render(Scene sc, Location o) {
@@ -846,7 +871,7 @@ public class PPTest extends JavaPlugin {
Location eye = o.clone().add(sc.camX, sc.camY, sc.camZ);
Vector look = o.clone().add(sc.lookX, sc.lookY, sc.lookZ).toVector().subtract(eye.toVector());
eye.setDirection(look);
RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") || sc.name.startsWith("geo_") || sc.name.startsWith("items_") || sc.name.startsWith("xmas_") ? new Resolution(960, 640) : new Resolution(480, 320), null);
RenderJob job = r.prepare(eye, sc.name.endsWith("_sq") ? new Resolution(384, 384) : sc.name.startsWith("proj_") || sc.name.startsWith("rf_") || sc.name.startsWith("pose_") || sc.name.startsWith("frames") || sc.name.startsWith("geo_") || sc.name.startsWith("liq_") || sc.name.startsWith("items_") || sc.name.startsWith("xmas_") ? new Resolution(960, 640) : new Resolution(480, 320), null);
if (sc.name.equals("dbg")) {
try {
java.lang.reflect.Field f = DefaultScreenRenderer.class.getDeclaredField("objectBaker");
@@ -2067,4 +2092,111 @@ public class PPTest extends JavaPlugin {
}
return mapItem(v);
}
// --- fluids ------------------------------------------------------------------------------------------------
/** Sets the biome of the scene area (deterministic water colour / water fog). */
private void biome(Location o, Biome biome) {
for (int x = -8; x < 20; x += 2)
for (int z = -14; z < 14; z += 2)
for (int y = -16; y < 16; y += 2)
w.setBiome(o.getBlockX() + x, o.getBlockY() + y, o.getBlockZ() + z, biome);
}
/** A block placed WITH physics (fluids schedule their flow ticks). */
private void putPhys(Location o, int x, int y, int z, String data) {
o.clone().add(x, y, z).getBlock().setBlockData(Bukkit.createBlockData("minecraft:" + data), true);
}
private void liqFlow(Location o) {
biome(o, Biome.PLAINS);
// Stone rim keeps the water on the platform.
for (int x = -5; x <= 16; x++)
for (int z = -11; z <= 10; z++)
if (x == -5 || x == 16 || z == -11 || z == 10) put(o, x, 0, z, "stone");
for (int x = 0; x <= 4; x++)
for (int z = -2; z <= 2; z++)
for (int y = 0; y <= 2; y++) put(o, x, y, z, "stone");
for (int x = 10; x <= 12; x++)
for (int z = -6; z <= -4; z++) {
put(o, x, -2, z, "sand");
put(o, x, -1, z, "water");
}
putPhys(o, 2, 3, 0, "water");
putPhys(o, 11, 0, 5, "water");
}
private static final String[] LOGGED = {
"oak_stairs[facing=north,waterlogged=true]", "stone_slab[type=bottom,waterlogged=true]",
"stone_slab[type=top,waterlogged=true]", "oak_fence[waterlogged=true]", "glass_pane[waterlogged=true]",
"seagrass", "kelp_plant", "tube_coral[waterlogged=true]", "oak_leaves[waterlogged=true,persistent=true]",
"lantern[waterlogged=true]", "sea_pickle[pickles=3,waterlogged=true]", "oak_trapdoor[half=bottom,open=false,waterlogged=true]"};
private void liqLogged(Location o) {
biome(o, Biome.PLAINS);
for (int x = -1; x <= LOGGED.length; x++)
for (int z = 0; z <= 2; z++) {
put(o, x, -2, z, "sand");
put(o, x, -1, z, "water");
}
for (int i = 0; i < LOGGED.length; i++) {
put(o, i, -1, 1, LOGGED[i]); // in the pool
put(o, i, 0, 5, LOGGED[i]); // in the air: water inside, surface at 8/9
}
// Under water: a waterlogged stair and slab with a water source above them.
put(o, 2, 0, 8, "oak_stairs[facing=south,waterlogged=true]");
put(o, 2, 1, 8, "water");
put(o, 4, 0, 8, "stone_slab[type=bottom,waterlogged=true]");
put(o, 4, 1, 8, "water");
// Water next to glass and leaves (overlay faces) on the floor.
put(o, 7, 0, 8, "water");
put(o, 8, 0, 8, "glass");
put(o, 9, 0, 8, "water");
put(o, 10, 0, 8, "oak_leaves[persistent=true]");
}
private void liqUnder(Location o, Biome biome) {
biome(o, biome);
for (int x = -5; x <= 17; x++)
for (int z = -11; z <= 11; z++)
for (int y = -9; y <= -1; y++) {
boolean wall = x == -5 || x == 17 || z == -11 || z == 11;
put(o, x, y, z, wall ? "stone_bricks" : y == -9 ? "sand" : "water");
}
String[] pillars = {"red_concrete", "yellow_concrete", "lime_concrete", "blue_concrete", "white_concrete", "orange_concrete"};
int[][] at = {{0, 0}, {4, -4}, {8, 4}, {12, -6}, {15, 8}, {16, -10}};
for (int i = 0; i < pillars.length; i++)
for (int y = -8; y <= -4; y++) put(o, at[i][0], y, at[i][1], pillars[i]);
put(o, 2, -8, 3, "gold_block");
put(o, 2, -7, 3, "glass");
put(o, 5, -8, 1, "oak_stairs[facing=west,waterlogged=true]");
for (int i = 0; i < 8; i++) put(o, -2 + i * 2, -8, -6 + (i % 3), "seagrass");
// Above the water: a bridge and lily pads, seen from below through the surface.
for (int x = 3; x <= 9; x++) put(o, x, 2, 1, "oak_planks");
put(o, 6, 0, -2, "lily_pad");
put(o, 7, 0, -1, "lily_pad");
}
private void liqLava(Location o) {
biome(o, Biome.PLAINS);
for (int x = -2; x <= 17; x++)
for (int z = -3; z <= 5; z++)
if (x == -2 || x == 17 || z == -3 || z == 5) put(o, x, 0, z, "stone");
for (int x = 2; x <= 4; x++)
for (int z = 0; z <= 2; z++) {
put(o, x, -2, z, "basalt");
put(o, x, -1, z, "lava");
}
// A source with flowing lava around it (overworld drop-off 2: amounts 8/6/4/2 = levels 0/2/4/6).
for (int dx = -3; dx <= 3; dx++)
for (int dz = -3; dz <= 3; dz++) {
int d = Math.abs(dx) + Math.abs(dz);
if (d <= 3) put(o, 9 + dx, 0, 1 + dz, d == 0 ? "lava" : "lava[level=" + (2 * d) + "]");
}
// Lava falling from a pillar.
for (int y = 0; y <= 2; y++) put(o, 14, y, 1, "stone");
put(o, 14, 3, 1, "lava");
put(o, 15, 3, 1, "lava[level=2]");
for (int y = 0; y <= 2; y++) put(o, 15, y, 1, "lava[level=10]");
}
}