Uses a minimal PNG writer (8-bit RGB, no row filtering) with a configurable deflate level instead of + * {@code ImageIO}, which is roughly twice as slow at full HD for a file that is only slightly smaller.
*/ public final class ImageEncoder { + private static final byte[] SIGNATURE = {(byte) 137, 80, 78, 71, 13, 10, 26, 10}; + private ImageEncoder() {} /** - * Encodes an image as Base64 PNG. + * Encodes an image as Base64 PNG with the fastest compression. * * @param image The image. * @return Base64 string without prefix. */ public static String toBase64Png(BufferedImage image) { + return toBase64Png(image, Deflater.BEST_SPEED); + } + + /** + * Encodes an image as Base64 PNG. + * + * @param image The image. + * @param compressionLevel Deflate level, 0 (store) to 9 (smallest, slowest). Values outside are clamped. + * @return Base64 string without prefix. + */ + public static String toBase64Png(BufferedImage image, int compressionLevel) { try { - ByteArrayOutputStream out = new ByteArrayOutputStream(image.getWidth() * image.getHeight()); - if (!ImageIO.write(image, "png", out)) { - throw new IOException("No PNG writer available"); - } - return Base64.getEncoder().encodeToString(out.toByteArray()); + return Base64.getEncoder().encodeToString(toPng(image, Math.max(0, Math.min(9, compressionLevel)))); } catch (IOException e) { throw new UncheckedIOException(e); } } + + /** + * Encodes an image as PNG bytes. + * + * @param image The image. + * @param level Deflate level, 0 to 9. + * @return The PNG file. + * @throws IOException never in practice, all streams are in memory. + */ + static byte[] toPng(BufferedImage image, int level) throws IOException { + int width = image.getWidth(); + int height = image.getHeight(); + int[] pixels = image.getType() == BufferedImage.TYPE_INT_RGB || image.getType() == BufferedImage.TYPE_INT_ARGB + ? ((DataBufferInt) image.getRaster().getDataBuffer()).getData() + : image.getRGB(0, 0, width, height, null, 0, width); + + byte[] raw = new byte[height * (1 + width * 3)]; + int o = 0; + for (int y = 0; y < height; y++) { + raw[o++] = 0; // filter type "none" + int row = y * width; + for (int x = 0; x < width; x++) { + int pixel = pixels[row + x]; + raw[o++] = (byte) (pixel >> 16); + raw[o++] = (byte) (pixel >> 8); + raw[o++] = (byte) pixel; + } + } + + Deflater deflater = new Deflater(level); + ByteArrayOutputStream compressed = new ByteArrayOutputStream(raw.length / 3); + try { + deflater.setInput(raw); + deflater.finish(); + byte[] buffer = new byte[1 << 16]; + while (!deflater.finished()) { + int n = deflater.deflate(buffer); + compressed.write(buffer, 0, n); + } + } finally { + deflater.close(); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(compressed.size() + 64); + out.write(SIGNATURE); + ByteArrayOutputStream header = new ByteArrayOutputStream(13); + DataOutputStream headerData = new DataOutputStream(header); + headerData.writeInt(width); + headerData.writeInt(height); + headerData.writeByte(8); // bit depth + headerData.writeByte(2); // colour type: RGB + headerData.writeByte(0); // compression + headerData.writeByte(0); // filter + headerData.writeByte(0); // interlace + writeChunk(out, "IHDR", header.toByteArray()); + writeChunk(out, "IDAT", compressed.toByteArray()); + writeChunk(out, "IEND", new byte[0]); + return out.toByteArray(); + } + + private static void writeChunk(ByteArrayOutputStream out, String type, byte[] data) throws IOException { + DataOutputStream stream = new DataOutputStream(out); + byte[] name = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + stream.writeInt(data.length); + stream.write(name); + stream.write(data); + CRC32 crc = new CRC32(); + crc.update(name); + crc.update(data); + stream.writeInt((int) crc.getValue()); + } } diff --git a/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java new file mode 100644 index 0000000..d13f39d --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java @@ -0,0 +1,25 @@ +package dev.loat.msmp_vision.logging; + +/** + * Logger that prefixes every message with the ID of the RPC connection it belongs to. + */ +public class RPCConnectionLogger { + + private RPCConnectionLogger() {} + + public static void debug(Integer connectionId, String message) { + Logger.debug("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void info(Integer connectionId, String message) { + Logger.info("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void warning(Integer connectionId, String message) { + Logger.warning("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void error(Integer connectionId, String message) { + Logger.error("RPC Connection #%s: %s".formatted(connectionId, message)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java index 8990a0e..41cfd30 100644 --- a/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java +++ b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java @@ -5,17 +5,18 @@ /** * Growable struct-of-arrays storage for lit, tinted quads in camera-relative coordinates. + * Every quad has one colour per vertex, so smooth lighting and ambient occlusion can vary across a face. */ public final class QuadBuffer { private float[] positions = new float[12 * 64]; private float[] uvs = new float[8 * 64]; - private int[] colors = new int[64]; + private int[] colors = new int[4 * 64]; private int[] textures = new int[64]; private int count; /** - * Appends a quad. + * Appends a quad with the same colour on all four vertices. * * @param pos 12 floats, block-local vertex positions. * @param offsetX X offset added to every vertex. @@ -25,32 +26,55 @@ public final class QuadBuffer { * @param texture Texture id. * @param rgb Vertex colour multiplier as {@code 0xRRGGBB}. */ - public void add( - float[] pos, - float offsetX, - float offsetY, - float offsetZ, - float[] uv, - int texture, - int rgb - ) { + public void add(float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int rgb) { + int slot = this.reserve(); + this.write(slot, pos, offsetX, offsetY, offsetZ, uv, texture, 0); + Arrays.fill(this.colors, slot * 4, slot * 4 + 4, rgb); + } + + /** + * Appends a quad with one colour per vertex. + * + * @param pos 12 floats, block-local vertex positions. + * @param offsetX X offset added to every vertex. + * @param offsetY Y offset added to every vertex. + * @param offsetZ Z offset added to every vertex. + * @param uv 8 floats of texture coordinates. + * @param texture Texture id. + * @param rgb 4 vertex colour multipliers as {@code 0xRRGGBB}, in the order of {@code pos}. + * @param rotate Number of vertices the quad is rotated by (0 or 1), which flips the diagonal it is split along. + */ + public void add(float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int[] rgb, int rotate) { + int slot = this.reserve(); + this.write(slot, pos, offsetX, offsetY, offsetZ, uv, texture, rotate); + for (int v = 0; v < 4; v++) { + this.colors[slot * 4 + v] = rgb[(v + rotate) & 3]; + } + } + + private int reserve() { if (this.count == this.textures.length) { int capacity = this.count * 2; this.positions = Arrays.copyOf(this.positions, capacity * 12); this.uvs = Arrays.copyOf(this.uvs, capacity * 8); - this.colors = Arrays.copyOf(this.colors, capacity); + this.colors = Arrays.copyOf(this.colors, capacity * 4); this.textures = Arrays.copyOf(this.textures, capacity); } - int p = this.count * 12; + return this.count++; + } + + private void write(int slot, float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int rotate) { + int p = slot * 12; + int t = slot * 8; for (int v = 0; v < 4; v++) { - this.positions[p + v * 3] = pos[v * 3] + offsetX; - this.positions[p + v * 3 + 1] = pos[v * 3 + 1] + offsetY; - this.positions[p + v * 3 + 2] = pos[v * 3 + 2] + offsetZ; + int s = (v + rotate) & 3; + this.positions[p + v * 3] = pos[s * 3] + offsetX; + this.positions[p + v * 3 + 1] = pos[s * 3 + 1] + offsetY; + this.positions[p + v * 3 + 2] = pos[s * 3 + 2] + offsetZ; + this.uvs[t + v * 2] = uv[s * 2]; + this.uvs[t + v * 2 + 1] = uv[s * 2 + 1]; } - System.arraycopy(uv, 0, this.uvs, this.count * 8, 8); - this.colors[this.count] = rgb; - this.textures[this.count] = texture; - this.count++; + this.textures[slot] = texture; } /** @return Number of quads. */ @@ -64,9 +88,10 @@ public void add( /** * @param quad Quad index. + * @param vertex Vertex index (0-3). * @return Colour multiplier as {@code 0xRRGGBB}. */ - public int color(int quad) { return this.colors[quad]; } + public int color(int quad, int vertex) { return this.colors[quad * 4 + vertex]; } /** * @param quad Quad index. diff --git a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java index c4a3378..18cd56e 100644 --- a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java +++ b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java @@ -10,13 +10,16 @@ import dev.loat.msmp_vision.world.SectionSnapshot; import dev.loat.msmp_vision.world.WorldSnapshot; import net.minecraft.core.Direction; +import net.minecraft.tags.BlockTags; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.material.FluidState; /** - * Builds the {@link SectionGeometry} of one section: block quads with flat lighting, face shading and biome tint, - * plus simple fluid surfaces. Vertex colours are fully precomputed so the fragment stage only multiplies. + * Builds the {@link SectionGeometry} of one section: block quads with smooth lighting and ambient occlusion + * (see {@link VertexLighting}), face shading and biome tint, plus simple fluid surfaces. Quads that do not lie on a + * block face (plants, slabs) and all quads with smooth lighting disabled get flat lighting. Vertex colours are fully + * precomputed so the fragment stage only multiplies. * *Instances hold scratch buffers and must not be shared between threads.
*/ @@ -25,6 +28,11 @@ public final class SectionMesher { /** Directional shading per {@link Direction#ordinal()}: DOWN, UP, NORTH, SOUTH, WEST, EAST. */ private static final float[] FACE_SHADE = {0.5f, 1.0f, 0.8f, 0.8f, 0.6f, 0.6f}; private static final float MIN_LIGHT = 0.04f; + /** + * Debug aid, enabled with the JVM flag {@code -Dmsmp_vision.debug.sections=true}: tints chunk columns in an + * alternating red/blue checkerboard so that chunk borders and missing columns become visible in the screenshot. + */ + private static final boolean DEBUG_SECTIONS = Boolean.getBoolean("msmp_vision.debug.sections"); private static final float[] FLUID_UV_TOP = {0, 0, 0, 1, 1, 1, 1, 0}; private final WorldSnapshot world; @@ -38,6 +46,11 @@ public final class SectionMesher { private final int blend; private final float[] scratch = new float[12]; private final float[] sideUv = new float[8]; + private final boolean smoothLighting; + private final VertexLighting lighting; + private final float[] brightness = new float[4]; + private final int[] vertexColors = new int[4]; + private int debugTint = 0xFFFFFF; /** * Creates a mesher. @@ -48,8 +61,9 @@ public final class SectionMesher { * @param tint Tint resolver. * @param camera Camera providing the coordinate origin. * @param biomeBlend Biome blend radius. + * @param smoothLighting Whether to blend light and darken corners (ambient occlusion) per vertex. */ - public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore textures, TintResolver tint, Camera camera, int biomeBlend) { + public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore textures, TintResolver tint, Camera camera, int biomeBlend, boolean smoothLighting) { this.world = world; this.environment = world.environment(); this.baker = baker; @@ -59,6 +73,8 @@ public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore te this.originY = camera.originY(); this.originZ = camera.originZ(); this.blend = biomeBlend; + this.smoothLighting = smoothLighting; + this.lighting = new VertexLighting(new WorldSamples(world), this.environment.skyLightFactor(), MIN_LIGHT); } /** @@ -70,6 +86,9 @@ public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore te public SectionGeometry mesh(SectionSnapshot section) { QuadBuffer opaque = new QuadBuffer(); QuadBuffer translucent = new QuadBuffer(); + if (DEBUG_SECTIONS) { + this.debugTint = ((section.sectionX() + section.sectionZ()) & 1) == 0 ? 0xFFA0A0 : 0xA0A0FF; + } int baseX = section.sectionX() << 4; int baseY = section.sectionY() << 4; int baseZ = section.sectionZ() << 4; @@ -115,11 +134,25 @@ private void meshBlock(BlockState state, boolean solid, int x, int y, int z, Qua continue; } Direction face = quad.face(); - float light = this.light(x, y, z, face, solid); float shade = quad.shade() ? FACE_SHADE[face.ordinal()] : 1.0f; - int tintRgb = quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF; - int color = pack(shade * light, tintRgb); - (quad.translucent() ? translucent : opaque).add(quad.positions(), x - this.originX, y - this.originY, z - this.originZ, quad.uvs(), quad.texture(), color); + int tintRgb = multiply(quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF, this.debugTint); + QuadBuffer target = quad.translucent() ? translucent : opaque; + float offsetX = x - this.originX; + float offsetY = y - this.originY; + float offsetZ = z - this.originZ; + + int axis = face.getAxis().ordinal(); + int sign = face.getStepX() + face.getStepY() + face.getStepZ(); + if (this.smoothLighting && VertexLighting.isFullFace(quad.positions(), axis, sign)) { + int rotate = this.lighting.compute(x, y, z, axis, sign, quad.positions(), shade, this.brightness); + for (int i = 0; i < 4; i++) { + this.vertexColors[i] = pack(this.brightness[i], tintRgb); + } + target.add(quad.positions(), offsetX, offsetY, offsetZ, quad.uvs(), quad.texture(), this.vertexColors, rotate); + } else { + float light = this.light(x, y, z, face, solid); + target.add(quad.positions(), offsetX, offsetY, offsetZ, quad.uvs(), quad.texture(), pack(shade * light, tintRgb)); + } } } @@ -128,7 +161,7 @@ private void meshFluid(FluidState fluid, int x, int y, int z, QuadBuffer opaque, TextureData still = this.textures.get(water ? "minecraft:block/water_still" : "minecraft:block/lava_still"); TextureData flow = this.textures.get(water ? "minecraft:block/water_flow" : "minecraft:block/lava_flow"); QuadBuffer target = water ? translucent : opaque; - int tintRgb = water ? this.tint.waterTint(this.world, x, y, z, this.blend) : 0xFFFFFF; + int tintRgb = multiply(water ? this.tint.waterTint(this.world, x, y, z, this.blend) : 0xFFFFFF, this.debugTint); float light = water ? this.light(x, y, z, Direction.UP, false) : 1.0f; FluidState above = this.world.blockState(x, y + 1, z).getFluidState(); @@ -170,6 +203,58 @@ private float light(int x, int y, int z, Direction face, boolean solid) { return Math.max(level / (4.0f - 3.0f * level), MIN_LIGHT); } + /** Adapts the captured world to the Minecraft-free {@link VertexLighting}; remembers the last looked-up block. */ + private static final class WorldSamples implements VertexLighting.Samples { + + private final WorldSnapshot world; + private int lastX; + private int lastY; + private int lastZ; + private BlockState last; + + WorldSamples(WorldSnapshot world) { + this.world = world; + } + + private BlockState state(int x, int y, int z) { + if (this.last == null || x != this.lastX || y != this.lastY || z != this.lastZ) { + this.last = this.world.blockState(x, y, z); + this.lastX = x; + this.lastY = y; + this.lastZ = z; + } + return this.last; + } + + @Override + public boolean opaque(int x, int y, int z) { + return this.state(x, y, z).isSolidRender(); + } + + @Override + public boolean occluder(int x, int y, int z) { + BlockState state = this.state(x, y, z); + return state.isSolidRender() || state.is(BlockTags.LEAVES); + } + + @Override + public int sky(int x, int y, int z) { + return this.world.skyLight(x, y, z); + } + + @Override + public int block(int x, int y, int z) { + return this.world.blockLight(x, y, z); + } + } + + private static int multiply(int a, int b) { + int r = ((a >> 16) & 0xFF) * ((b >> 16) & 0xFF) / 255; + int g = ((a >> 8) & 0xFF) * ((b >> 8) & 0xFF) / 255; + int bl = (a & 0xFF) * (b & 0xFF) / 255; + return (r << 16) | (g << 8) | bl; + } + private static int pack(float brightness, int tintRgb) { int r = clamp(brightness * ((tintRgb >> 16) & 0xFF)); int g = clamp(brightness * ((tintRgb >> 8) & 0xFF)); diff --git a/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java b/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java new file mode 100644 index 0000000..e397949 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java @@ -0,0 +1,179 @@ +package dev.loat.msmp_vision.mesh; + +/** + * Smooth lighting and ambient occlusion of a quad, following Minecraft's "smooth lighting" algorithm. + * + *For every vertex of a face, the four blocks around that vertex in the plane of the face are sampled + * (the block in front of the face and its two edge neighbours plus the diagonal one). Their light is averaged, + * and their ambient occlusion shade (0.2 for blocks that fill the whole cube, 1.0 otherwise) is averaged as well. + * If both edge neighbours are opaque, the diagonal block is hidden and is replaced by an edge neighbour, which + * prevents light from leaking through corners. Samples inside opaque blocks use the light of the centre sample + * instead of their own (zero) light, so only the occlusion shade darkens a corner.
+ * + *This class does not depend on Minecraft types. Instances hold scratch buffers and must not be shared + * between threads.
+ */ +public final class VertexLighting { + + /** Shade of a block that fills its whole cube, compared to 1.0 for everything else. */ + public static final float OCCLUDED_SHADE = 0.2f; + + /** Access to the surrounding world. */ + public interface Samples { + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return {@code true} if the block hides what is behind it (used for corner handling and light sampling). + */ + boolean opaque(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return {@code true} if the block darkens neighbouring faces (ambient occlusion). + */ + boolean occluder(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Sky light 0-15. + */ + int sky(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Block light 0-15. + */ + int block(int x, int y, int z); + } + + private final Samples samples; + private final float skyLightFactor; + private final float minLight; + private final int[] sideU = new int[3]; + private final int[] sideV = new int[3]; + private final int[] corner = new int[3]; + + /** + * Creates a calculator. + * + * @param samples The world. + * @param skyLightFactor Sky light multiplier, 0 (night) to 1 (day). + * @param minLight Brightness floor, so that fully dark places do not become pitch black. + */ + public VertexLighting(Samples samples, float skyLightFactor, float minLight) { + this.samples = samples; + this.skyLightFactor = skyLightFactor; + this.minLight = minLight; + } + + /** + * Tells whether a quad lies in the plane of the block face in direction {@code axis}/{@code sign}. + * Only such quads are lit from the neighbouring block; others (plants, slabs) use flat lighting. + * + * @param pos 4 vertices x 3 floats, block-local. + * @param axis Axis of the face normal (0 = X, 1 = Y, 2 = Z). + * @param sign Direction along the axis (+1 or -1). + * @return {@code true} if all four vertices lie on that face of the block. + */ + public static boolean isFullFace(float[] pos, int axis, int sign) { + float expected = sign > 0 ? 1f : 0f; + for (int v = 0; v < 4; v++) { + if (Math.abs(pos[v * 3 + axis] - expected) > 1e-3f) { + return false; + } + } + return true; + } + + /** + * Computes the brightness of all four vertices of a face. + * + * @param x Block X. + * @param y Block Y. + * @param z Block Z. + * @param axis Axis of the face normal (0 = X, 1 = Y, 2 = Z). + * @param sign Direction along the axis (+1 or -1). + * @param pos 4 vertices x 3 floats, block-local. + * @param directionalShade Constant per-face shade (1.0 up, 0.5 down, ...), 1.0 if the quad is unshaded. + * @param out Receives 4 brightness values (0..1), without biome tint. + * @return {@code 1} if the quad should be rotated by one vertex so that its diagonal follows the darker + * vertex pair (avoids visible artefacts), otherwise {@code 0}. + */ + public int compute(int x, int y, int z, int axis, int sign, float[] pos, float directionalShade, float[] out) { + int bx = axis == 0 ? x + sign : x; + int by = axis == 1 ? y + sign : y; + int bz = axis == 2 ? z + sign : z; + int u = axis == 0 ? 1 : 0; + int v = axis == 2 ? 1 : 2; + + float centerShade = this.samples.occluder(bx, by, bz) ? OCCLUDED_SHADE : 1f; + int centerSky = this.samples.sky(bx, by, bz); + int centerBlock = this.samples.block(bx, by, bz); + + for (int i = 0; i < 4; i++) { + int su = pos[i * 3 + u] >= 0.5f ? 1 : -1; + int sv = pos[i * 3 + v] >= 0.5f ? 1 : -1; + + this.sideU[0] = bx; this.sideU[1] = by; this.sideU[2] = bz; + this.sideU[u] += su; + this.sideV[0] = bx; this.sideV[1] = by; this.sideV[2] = bz; + this.sideV[v] += sv; + this.corner[0] = this.sideU[0]; this.corner[1] = this.sideU[1]; this.corner[2] = this.sideU[2]; + this.corner[v] += sv; + + boolean opaqueU = this.samples.opaque(this.sideU[0], this.sideU[1], this.sideU[2]); + boolean opaqueV = this.samples.opaque(this.sideV[0], this.sideV[1], this.sideV[2]); + + float shadeU = this.shade(this.sideU); + float shadeV = this.shade(this.sideV); + float skyU = opaqueU ? centerSky : this.samples.sky(this.sideU[0], this.sideU[1], this.sideU[2]); + float skyV = opaqueV ? centerSky : this.samples.sky(this.sideV[0], this.sideV[1], this.sideV[2]); + float blockU = opaqueU ? centerBlock : this.samples.block(this.sideU[0], this.sideU[1], this.sideU[2]); + float blockV = opaqueV ? centerBlock : this.samples.block(this.sideV[0], this.sideV[1], this.sideV[2]); + + float shadeC; + float skyC; + float blockC; + if (opaqueU && opaqueV) { + shadeC = shadeU; + skyC = skyU; + blockC = blockU; + } else { + boolean opaqueC = this.samples.opaque(this.corner[0], this.corner[1], this.corner[2]); + shadeC = this.shade(this.corner); + skyC = opaqueC ? centerSky : this.samples.sky(this.corner[0], this.corner[1], this.corner[2]); + blockC = opaqueC ? centerBlock : this.samples.block(this.corner[0], this.corner[1], this.corner[2]); + } + + float shade = (centerShade + shadeU + shadeV + shadeC) * 0.25f; + float sky = (centerSky + skyU + skyV + skyC) * 0.25f; + float block = (centerBlock + blockU + blockV + blockC) * 0.25f; + out[i] = directionalShade * shade * this.brightness(sky, block); + } + return out[0] + out[2] > out[1] + out[3] ? 1 : 0; + } + + /** + * Brightness for a light level, using Minecraft's light curve. + * + * @param sky Sky light 0-15. + * @param block Block light 0-15. + * @return Brightness 0..1, at least the configured floor. + */ + public float brightness(float sky, float block) { + float level = Math.max(sky / 15f * this.skyLightFactor, block / 15f); + return Math.max(level / (4f - 3f * level), this.minLight); + } + + private float shade(int[] p) { + return this.samples.occluder(p[0], p[1], p[2]) ? OCCLUDED_SHADE : 1f; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java index 0f46fe1..f0b732a 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java @@ -8,6 +8,11 @@ import java.util.function.Supplier; +/** + * Central registration point for all {@code vision} MSMP endpoints. + * + *Each endpoint is implemented in its own sub-package and registered here.
+ */ public class Endpoints { private Endpoints() {} @@ -15,10 +20,11 @@ private Endpoints() {} * Registers all endpoints on the given {@link MSMPNamespace}. * * @param namespace The namespace to register all endpoints under - * @param msmpServer A supplier for the MSMPServer instance, used by some endpoints to subscribe to server events + * @param msmpServer A supplier for the MSMPServer instance, used by endpoints that push notifications + * @param service The service that renders the screenshots */ public static void register(MSMPNamespace namespace, SupplierRenders a server-side screenshot from the given position and rotation and returns it + * as a Base64 encoded PNG. No client is involved, the image is rendered entirely on the server.
+ * + *Example request:
+ *
+ * {
+ * "jsonrpc": "2.0",
+ * "id": 1,
+ * "method": "vision:screenshot",
+ * "params": [{
+ * "position": [128.5, 80.0, -32.3],
+ * "rotation": [90.0, 0.0],
+ * "width": 512,
+ * "height": 288
+ * }]
+ * }
+ *
+ *
+ * Example response:
+ *
+ * {
+ * "image": "iVBORw0KGgoAAAANSUhEUg...",
+ * "format": "png",
+ * "width": 512,
+ * "height": 288,
+ * "render_time_ms": 412
+ * }
+ *
+ */
public class Screenshot {
private Screenshot() {}
/**
- * Registers the methods. Must be called during mod initialization, before the registry freezes.
+ * Registers the {@code vision:screenshot} method on the given {@link MSMPNamespace}.
+ *
+ * Validation and rendering are delegated to {@link VisionService}. Values that are not + * part of the request are taken from the {@code defaults} section of the config, values + * exceeding the {@code limits} section are rejected.
* - * @param namespace The MSMP namespace to register under. - * @param service The service handling the requests. + * @param namespace The namespace to register this method under + * @param service The service handling the requests */ public static void register(MSMPNamespace namespace, VisionService service) { namespace.method("screenshot") - .description("Renders a server-side screenshot and returns it as a Base64 encoded PNG.") + .description("Renders a server-side screenshot and returns it as a Base64 encoded PNG") .requestSchema(ScreenshotRequest.SCHEMA) .responseSchema(ScreenshotResponse.SCHEMA) - .register((server, client, request) -> service.screenshot(server, request)); + .register((server, client, params) -> { + try { + ScreenshotResponse response = service.screenshot(server, params); + RPCConnectionLogger.info(client.connectionId(), + String.format(Locale.ROOT, + "vision:screenshot - rendered %dx%d in %dms at (%f, %f, %f)", + response.width(), + response.height(), + response.renderTimeMs(), + params.position().get(0), + params.position().get(1), + params.position().get(2) + ) + ); + return response; + } catch (RuntimeException e) { + RPCConnectionLogger.warning(client.connectionId(), "vision:screenshot - " + e.getMessage()); + throw e; + } + }); } } diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java index e81d5ab..e242b32 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java @@ -1,69 +1,89 @@ package dev.loat.msmp_vision.msmp.endpoints.screenshot; -import java.util.List; - import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; - import net.minecraft.server.jsonrpc.api.Schema; +import java.util.List; +import java.util.Optional; + + /** - * Parameters of the {@code msmp_vision:screenshot} method. + * Request payload for the {@code vision:screenshot} method. * - *Optional numeric fields use a sentinel value meaning "use the configured default": - * {@code 0} for {@code width}, {@code height}, {@code fov}, {@code render_distance}, {@code ssaa} - * and {@code -1} for {@code biome_blend}.
+ *Only {@code position} is required. Every omitted optional field falls back to the + * value from the {@code defaults} section of the config. Vectors are transferred as arrays, + * exactly like in the {@code entity} namespace.
* - * @param dimension Dimension id, e.g. {@code minecraft:overworld}. - * @param position The target position as {@code [x, y, z]} - * @param yaw Yaw in degrees (Minecraft convention, 0 = south, 90 = west). - * @param pitch Pitch in degrees (positive = looking down). - * @param width Output width in pixels, {@code 0} for default. - * @param height Output height in pixels, {@code 0} for default. - * @param fov Vertical field of view in degrees, {@code 0} for default. - * @param renderDistance Render distance in blocks, {@code 0} for default. - * @param ssaa Supersampling factor, {@code 0} for default. - * @param biomeBlend Biome tint blend radius, {@code -1} for default. + *Example JSON representations:
+ *
+ * // Minimal
+ * { "position": [128.5, 80.0, -32.3] }
+ *
+ * // Everything set
+ * {
+ * "dimension": "minecraft:overworld",
+ * "position": [128.5, 80.0, -32.3],
+ * "rotation": [90.0, 0.0],
+ * "width": 512,
+ * "height": 288,
+ * "fov": 70.0,
+ * "render_distance": 64,
+ * "ssaa": 2,
+ * "biome_blend": 1
+ * }
+ *
+ *
+ * @param dimension The dimension resource key, if provided (default: {@code minecraft:overworld})
+ * @param position The camera position as {@code [x, y, z]}
+ * @param rotation The camera rotation as {@code [yaw, pitch]}, if provided (Minecraft convention: yaw 0 = south, pitch positive = down; default: {@code [0, 0]})
+ * @param width The output width in pixels, if provided
+ * @param height The output height in pixels, if provided
+ * @param fov The vertical field of view in degrees, if provided
+ * @param renderDistance The render distance in blocks, if provided
+ * @param ssaa The supersampling factor (1 = off), if provided
+ * @param biomeBlend The biome tint blend radius in blocks (0 = off), if provided
*/
public record ScreenshotRequest(
- String dimension,
+ OptionalExample JSON representation:
+ *
+ * {
+ * "image": "iVBORw0KGgoAAAANSUhEUg...",
+ * "format": "png",
+ * "width": 512,
+ * "height": 288,
+ * "render_time_ms": 412
+ * }
+ *
+ *
+ * @param image Base64 encoded image data (no data-URL prefix)
+ * @param format Image format, currently always {@code png}
+ * @param width Image width in pixels
+ * @param height Image height in pixels
+ * @param renderTimeMs Total server-side time for the request in milliseconds
*/
public record ScreenshotResponse(String image, String format, int width, int height, int renderTimeMs) {
-
+
/**
* Codec for serializing and deserializing {@link ScreenshotResponse} instances.
*/
- public static final CodecUsed for method-specific validation errors, such as a wrongly sized + * {@code position} array or values exceeding the configured limits.
+ */ +public class InvalidParamsException extends MSMPException { + + /** + * @param message A description of which parameter is invalid and why + */ + public InvalidParamsException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java new file mode 100644 index 0000000..cc4b43e --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java @@ -0,0 +1,22 @@ +package dev.loat.msmp_vision.msmp.exceptions; + +import net.minecraft.server.jsonrpc.methods.InvalidParameterJsonRpcException; + + +/** + * Base exception for all MSMP vision errors. + * + *All method-specific exceptions extend this class, allowing handlers + * to catch all MSMP errors with a single {@code catch (MSMPException e)} block.
+ */ +public class MSMPException extends InvalidParameterJsonRpcException { + + /** + * Creates a new {@link MSMPException} with the given message. + * + * @param message A human-readable description of the error + */ + public MSMPException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java new file mode 100644 index 0000000..9d583a1 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java @@ -0,0 +1,14 @@ +package dev.loat.msmp_vision.msmp.exceptions; + +/** + * Thrown when a requested dimension identifier is not registered on the server. + */ +public class UnknownDimensionException extends MSMPException { + + /** + * @param dimension The dimension identifier that could not be resolved + */ + public UnknownDimensionException(String dimension) { + super("Unknown dimension: " + dimension); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/Fog.java b/src/main/java/dev/loat/msmp_vision/raster/Fog.java index fbfc5f5..fc049f6 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/Fog.java +++ b/src/main/java/dev/loat/msmp_vision/raster/Fog.java @@ -1,39 +1,78 @@ package dev.loat.msmp_vision.raster; /** - * Linear distance fog. + * Linear, spherical distance fog. * - * @param start Distance where fog begins. - * @param end Distance of full fog. - * @param r Fog red. - * @param g Fog green. - * @param b Fog blue. + *The distance is the real distance from the camera to the fragment, not just the depth along the view axis, + * so the fog forms a sphere around the camera like in Minecraft instead of a flat wall in front of it.
*/ -public record Fog( - float start, - float end, - int r, - int g, - int b -) { +public final class Fog { + + private final float start; + private final float end; + private final int r; + private final int g; + private final int b; + private final float tanX; + private final float tanY; + private final float pixelToNdcX; + private final float pixelToNdcY; + private final float maxScale; + + private Fog(float start, float end, int r, int g, int b, float tanX, float tanY, int width, int height) { + this.start = start; + this.end = end; + this.r = r; + this.g = g; + this.b = b; + this.tanX = tanX; + this.tanY = tanY; + this.pixelToNdcX = 2f / width; + this.pixelToNdcY = 2f / height; + // Largest ratio of real distance to view depth, reached in the corners of the screen + this.maxScale = (float) Math.sqrt(1f + tanX * tanX + tanY * tanY); + } /** * Creates fog from a packed colour. * - * @param start Start distance. - * @param end End distance. + * @param start Distance in blocks where fog begins. + * @param end Distance in blocks of full fog. * @param rgb Colour as {@code 0xRRGGBB}. + * @param tanHalfFov {@code tan(fov / 2)} of the vertical field of view. + * @param aspect Width divided by height. + * @param width Framebuffer width. + * @param height Framebuffer height. * @return The fog. */ - public static Fog of(float start, float end, int rgb) { - return new Fog(start, end, (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + public static Fog of(float start, float end, int rgb, float tanHalfFov, float aspect, int width, int height) { + return new Fog(start, end, (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, tanHalfFov * aspect, tanHalfFov, width, height); } + /** @return Fog red. */ + public int r() { return this.r; } + + /** @return Fog green. */ + public int g() { return this.g; } + + /** @return Fog blue. */ + public int b() { return this.b; } + /** - * @param distance View-space depth. + * Visibility of a fragment. + * + * @param x Pixel column. + * @param y Pixel row. + * @param w View-space depth of the fragment (distance along the view axis). * @return Visibility 1 (no fog) to 0 (full fog). */ - public float factor(float distance) { + public float factor(int x, int y, float w) { + if (w * this.maxScale <= this.start) { + return 1f; // even the farthest pixel of this depth is still before the fog + } + float nx = ((x + 0.5f) * this.pixelToNdcX - 1f) * this.tanX; + float ny = (1f - (y + 0.5f) * this.pixelToNdcY) * this.tanY; + float distance = w * (float) Math.sqrt(1f + nx * nx + ny * ny); float range = this.end - this.start; if (range <= 0) { return distance < this.end ? 1f : 0f; diff --git a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java index 105f1a7..5ec6021 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java +++ b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java @@ -49,6 +49,10 @@ public BufferedImage toImage(int factor) { int outHeight = this.height / factor; BufferedImage image = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_INT_RGB); int[] target = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + if (factor == 1) { + System.arraycopy(this.color, 0, target, 0, target.length); + return image; + } int samples = factor * factor; for (int y = 0; y < outHeight; y++) { for (int x = 0; x < outWidth; x++) { diff --git a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java index c09c84a..4943169 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java +++ b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java @@ -59,10 +59,6 @@ public TriangleBuffer project(QuadBuffer quads) { private boolean load(QuadBuffer quads, int q) { float[] pos = quads.positions(); float[] uv = quads.uvs(); - int color = quads.color(q); - float r = ((color >> 16) & 0xFF) / 255f; - float g = ((color >> 8) & 0xFF) / 255f; - float b = (color & 0xFF) / 255f; boolean left = true, right = true, below = true, above = true, behind = true; for (int v = 0; v < 4; v++) { @@ -77,9 +73,10 @@ private boolean load(QuadBuffer quads, int q) { c[2] = w; c[3] = uv[q * 8 + v * 2]; c[4] = uv[q * 8 + v * 2 + 1]; - c[5] = r; - c[6] = g; - c[7] = b; + int color = quads.color(q, v); + c[5] = ((color >> 16) & 0xFF) / 255f; + c[6] = ((color >> 8) & 0xFF) / 255f; + c[7] = (color & 0xFF) / 255f; if (x >= -w) left = false; if (x <= w) right = false; if (y >= -w) below = false; @@ -106,9 +103,9 @@ private void emit(TriangleBuffer out, int texture, float[] a, float[] b, float[] d[2] = invW; d[3] = s[3] * invW; d[4] = s[4] * invW; - d[5] = s[5]; - d[6] = s[6]; - d[7] = s[7]; + d[5] = s[5] * invW; + d[6] = s[6] * invW; + d[7] = s[7] * invW; } for (int i = 1; i < n - 1; i++) { float[] p = this.screen[0]; diff --git a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java index 6a20ae8..fd6b875 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java +++ b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java @@ -183,10 +183,10 @@ private void drawTriangle(float[] d, int o, TextureData texture, boolean translu if (alpha == 0) { continue; } - float lr = (float) (w0 * ar + w1 * br + w2 * cr); - float lg = (float) (w0 * ag + w1 * bg + w2 * cg); - float lb = (float) (w0 * ab + w1 * bb + w2 * cb); - float f = fog.factor(w); + float lr = (float) (w0 * ar + w1 * br + w2 * cr) * w; + float lg = (float) (w0 * ag + w1 * bg + w2 * cg) * w; + float lb = (float) (w0 * ab + w1 * bb + w2 * cb) * w; + float f = fog.factor(x, y, w); int r = Math.min(255, (int) (((texel >> 16) & 0xFF) * lr * f + fog.r() * (1f - f))); int g = Math.min(255, (int) (((texel >> 8) & 0xFF) * lg * f + fog.g() * (1f - f))); int b = Math.min(255, (int) ((texel & 0xFF) * lb * f + fog.b() * (1f - f))); diff --git a/src/main/java/dev/loat/msmp_vision/render/Camera.java b/src/main/java/dev/loat/msmp_vision/render/Camera.java index f5c6518..311346f 100644 --- a/src/main/java/dev/loat/msmp_vision/render/Camera.java +++ b/src/main/java/dev/loat/msmp_vision/render/Camera.java @@ -50,7 +50,7 @@ public Camera(RenderSettings settings) { (float) (settings.position().get(2) - this.originZ) ); Matrix4f matrix = new Matrix4f() - .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistance() + 64f) + .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistanceBlocks() + 64f) .lookAt(eye, new Vector3f(eye).add(this.forward), new Vector3f(0, 1, 0)); matrix.get(this.viewProjection); this.frustum = new FrustumIntersection(matrix); @@ -97,4 +97,19 @@ public boolean intersectsSection(int sectionX, int sectionY, int sectionZ) { float minZ = (sectionZ << 4) - this.originZ; return this.frustum.testAab(minX, minY, minZ, minX + 16, minY + 16, minZ + 16); } + + /** + * Tests whether a whole chunk column may be visible. Used to decide which chunks are worth loading. + * + * @param chunkX Chunk X. + * @param chunkZ Chunk Z. + * @param minY Lowest block Y of the world. + * @param maxY Highest block Y of the world, exclusive. + * @return {@code false} if the column is certainly outside the view frustum. + */ + public boolean intersectsColumn(int chunkX, int chunkZ, int minY, int maxY) { + float minX = (chunkX << 4) - this.originX; + float minZ = (chunkZ << 4) - this.originZ; + return this.frustum.testAab(minX, minY - this.originY, minZ, minX + 16, maxY - this.originY, minZ + 16); + } } diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java index ade7cda..6440803 100644 --- a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java +++ b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java @@ -20,10 +20,11 @@ public final class RenderExecutor { /** * Creates the pool. * - * @param configuredThreads Thread count, {@code 0} selects the number of available processors. + * @param configuredThreads Thread count, {@code 0} selects the number of available processors minus one (at least 1), + * so the server thread keeps a core. */ public RenderExecutor(int configuredThreads) { - this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors()); + this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors() - 1); AtomicInteger counter = new AtomicInteger(); this.pool = Executors.newFixedThreadPool(this.threads, runnable -> { Thread thread = new Thread(runnable, "msmp-vision-render-" + counter.incrementAndGet()); diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java index 99ce9d5..4943a47 100644 --- a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java +++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java @@ -6,21 +6,20 @@ import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig; import dev.loat.msmp_vision.config.files.limits.LimitsConfig; import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest; +import dev.loat.msmp_vision.msmp.exceptions.InvalidParamsException; /** * Immutable, validated parameters of a single render. The only place where request values, config defaults * and config limits are combined; every renderer stage reads from here. * - * @param x Camera X. - * @param y Camera Y. - * @param z Camera Z. + * @param position Camera position as {@code [x, y, z]}. * @param yaw Yaw in degrees (Minecraft convention, 0 = south). * @param pitch Pitch in degrees (positive = down). * @param width Output width in pixels. * @param height Output height in pixels. * @param fov Vertical field of view in degrees. - * @param renderDistance Render distance in blocks. + * @param renderDistance Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings. * @param ssaa Supersampling factor (1 = off). * @param biomeBlend Biome tint blend radius in blocks. */ @@ -36,36 +35,54 @@ public record RenderSettings( int biomeBlend ) { + /** Pitch is clamped slightly below +-90 degrees so the camera's up vector never degenerates. */ + private static final double MAX_PITCH = 89.9; + /** * Builds validated settings. * - * @param request The request; zero values select the config default. + * @param request The request; omitted optional values select the config default. * @param config The configuration providing defaults and limits. * @return The settings. - * @throws IllegalArgumentException if a value exceeds the configured limits. + * @throws InvalidParamsException if a value is malformed or exceeds the configured limits. */ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFile config) { DefaultsConfig defaultsConfig = config.defaults; LimitsConfig limitsConfig = config.limits; - int width = request.width() > 0 ? request.width() : defaultsConfig.width; - int height = request.height() > 0 ? request.height() : defaultsConfig.height; - double fov = request.fov() > 0 ? request.fov() : defaultsConfig.fov; - int distance = request.renderDistance() > 0 ? request.renderDistance() : defaultsConfig.renderDistance; - int ssaa = request.ssaa() > 0 ? request.ssaa() : defaultsConfig.ssaa; - int blend = request.biomeBlend() >= 0 ? request.biomeBlend() : defaultsConfig.biomeBlend; - - require(width <= limitsConfig.maxWidth, "width exceeds the limit of " + limitsConfig.maxWidth); - require(height <= limitsConfig.maxHeight, "height exceeds the limit of " + limitsConfig.maxHeight); - require(distance <= limitsConfig.maxRenderDistance, "renderDistance exceeds the limit of " + limitsConfig.maxRenderDistance); - require(ssaa <= limitsConfig.maxSsaa, "ssaa exceeds the limit of " + limitsConfig.maxSsaa); - require(fov >= limitsConfig.minFov && fov <= limitsConfig.maxFov, "fov must be between " + limitsConfig.minFov + " and " + limitsConfig.maxFov); + ListChunks that are not loaded are loaded on demand (see {@link ChunksConfig}). Each chunk is copied into + * the snapshot right after it became available, so it does not have to stay loaded until the render starts. + * Chunks are processed nearest first, so a limit or timeout only costs the far end of the picture.
*/ public final class SnapshotCapture { + private static final int FALLBACK_FOG = 0xC0D8FF; + private static final int FALLBACK_SKY = 0x78A7FF; + + /** A chunk column that may be visible, with its squared horizontal distance to the camera. */ + private record Column(int x, int z, double distanceSquared) {} + private SnapshotCapture() {} /** - * Captures all loaded, potentially visible sections. + * Captures all potentially visible sections, loading missing chunks if configured. * * @param level The level. * @param camera The camera used for frustum culling. * @param settings The render settings. + * @param chunks The chunk loading configuration. * @return The snapshot. */ - public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings) { + public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings, ChunksConfig chunks) { Vec3 position = new Vec3(settings.position().get(0), settings.position().get(1), settings.position().get(2)); - Vector3f fog = new Vector3f(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position)).mul(255.0f); EnvironmentSnapshot environment = new EnvironmentSnapshot( level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD, level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position), - (int) fog.x << 16 | (int) fog.y << 8 | (int) fog.z, + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position), FALLBACK_FOG), + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position), FALLBACK_SKY), level.dimensionType().hasSkyLight() ); WorldSnapshot snapshot = new WorldSnapshot(environment); - double radius = settings.renderDistance() + 16; - int minChunkX = Mth.floor(settings.position().get(0) - radius) >> 4; - int maxChunkX = Mth.floor(settings.position().get(0) + radius) >> 4; - int minChunkZ = Mth.floor(settings.position().get(2) - radius) >> 4; - int maxChunkZ = Mth.floor(settings.position().get(2) + radius) >> 4; - int minSectionY = level.getMinSectionY(); + double radius = settings.renderDistanceBlocks() + 16; + ServerChunkCache cache = level.getChunkSource(); LevelLightEngine light = level.getLightEngine(); + int minSectionY = level.getMinSectionY(); - for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { - for (int cx = minChunkX; cx <= maxChunkX; cx++) { - LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); - if (chunk == null) { + long start = System.nanoTime(); + long deadline = start + TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds)); + ListChunks that are not loaded are loaded on demand (see {@link ChunksConfig}). Each chunk is copied into + * the snapshot right after it became available, so it does not have to stay loaded until the render starts. + * Chunks are processed nearest first, so a limit or timeout only costs the far end of the picture.
+ * + *{@link #step(long)} does a bounded amount of work and can be called once per server tick, which keeps the + * server responsive while a large area is loaded. Calling it with {@link Long#MAX_VALUE} does everything at once.
+ */ +public final class SnapshotSession { + + private static final int FALLBACK_FOG = 0xC0D8FF; + private static final int FALLBACK_SKY = 0x78A7FF; + + /** A chunk column that may be visible, with its squared horizontal distance to the camera. */ + private record Column(int x, int z, double distanceSquared) {} + + private final ServerLevel level; + private final Camera camera; + private final RenderSettings settings; + private final ChunksConfig chunks; + private final WorldSnapshot snapshot; + private final EnvironmentSnapshot environment; + private final List