From 4348b5fca0d8e80e952d75e7b2b853e09f7e02c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 22:07:02 +0100 Subject: [PATCH] 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 --- .../pixelpics/assets/BlockModelRegistry.java | 48 +- .../render/raytrace/CameraMedium.java | 133 ++++ .../render/raytrace/FluidRenderer.java | 631 ++++++++++++++++++ .../render/raytrace/SnapshotRaytracer.java | 264 ++++---- .../render/render/DefaultScreenRenderer.java | 23 +- .../pixelpics/render/tint/BiomeClimate.java | 95 ++- .../render/tint/BiomeTintProvider.java | 5 + tools/edgecase-plugin/pptest/PPTest.java | 136 +++- 8 files changed, 1160 insertions(+), 175 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java index 73c8948..400fbf0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/BlockModelRegistry.java @@ -27,11 +27,14 @@ public final class BlockModelRegistry { private final Map cache = new ConcurrentHashMap<>(); private final Map 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 buildInvisibleMaterials() { EnumSet 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); - } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java new file mode 100644 index 0000000..b28ae8a --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/CameraMedium.java @@ -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. + * + *

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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java new file mode 100644 index 0000000..8fe88a6 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FluidRenderer.java @@ -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. + * + *

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 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 OVERLAY_NEIGHBOURS = buildOverlayNeighbours(); + + private static EnumSet materials(String... names) { + EnumSet 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 buildOverlayNeighbours() { + EnumSet 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 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; + }; + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java index 8968957..8772c28 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java @@ -11,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 WATER_OVERLAY_NEIGHBOURS = buildWaterOverlayNeighbours(); - - private static EnumSet buildWaterOverlayNeighbours() { - EnumSet set = EnumSet.noneOf(Material.class); - for (Material m : Material.values()) { - String n = m.name(); - if (m.isLegacy()) continue; - if (n.equals("GLASS") - || n.equals("TINTED_GLASS") - || n.endsWith("_STAINED_GLASS") - || n.equals("ICE") - || n.equals("FROSTED_ICE") - || n.equals("SLIME_BLOCK") - || n.equals("HONEY_BLOCK") - || n.endsWith("_LEAVES")) set.add(m); - } - return set; - } - private final BlockModelRegistry registry; + private final 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]); } /** diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 0e305c0..b777e5f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -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 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 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 nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE); List 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 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 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 rayMap = new ArrayList<>(width * height); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java index ad51ad0..c3816be 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeClimate.java @@ -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); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java index 3444481..d9de4a1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/tint/BiomeTintProvider.java @@ -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(); diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 66b064d..452fa86 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -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]"); + } }