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, Supplier msmpServer, VisionService service) {
-
+
Screenshot.register(namespace, service);
}
}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
index a830969..fdd125b 100644
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
@@ -2,24 +2,71 @@
import dev.loat.msmp.MSMPNamespace;
import dev.loat.msmp_vision.VisionService;
+import dev.loat.msmp_vision.logging.RPCConnectionLogger;
+/**
+ * Registers the {@code vision:screenshot} MSMP method.
+ *
+ * Renders 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}.
*
- * @param namespace The MSMP namespace to register under.
- * @param service The service handling the requests.
+ * 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 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(),
+ "vision:screenshot - rendered %dx%d in %d ms".formatted(response.width(), response.height(), response.renderTimeMs()));
+ 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,
+ Optional dimension,
List position,
- float yaw,
- float pitch,
- int width,
- int height,
- double fov,
- int renderDistance,
- int ssaa,
- int biomeBlend
+ Optional> rotation,
+ Optional width,
+ Optional height,
+ Optional fov,
+ Optional renderDistance,
+ Optional ssaa,
+ Optional biomeBlend
) {
-
- /** Default dimension used when the request omits one. */
- public static final String DEFAULT_DIMENSION = "minecraft:overworld";
private static final Schema> POSITION_SCHEMA =
Schema.ofType("array", Codec.DOUBLE.listOf());
-
- /** Codec used for (de)serialization. */
- public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
- Codec.STRING.optionalFieldOf("dimension", DEFAULT_DIMENSION).forGetter(ScreenshotRequest::dimension),
+
+ private static final Schema> ROTATION_SCHEMA =
+ Schema.ofType("array", Codec.DOUBLE.listOf());
+
+ /**
+ * Codec for serializing and deserializing {@link ScreenshotRequest} instances.
+ */
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.optionalFieldOf("dimension").forGetter(ScreenshotRequest::dimension),
Codec.DOUBLE.listOf().fieldOf("position").forGetter(ScreenshotRequest::position),
- Codec.FLOAT.optionalFieldOf("yaw", 0f).forGetter(ScreenshotRequest::yaw),
- Codec.FLOAT.optionalFieldOf("pitch", 0f).forGetter(ScreenshotRequest::pitch),
- Codec.INT.optionalFieldOf("width", 0).forGetter(ScreenshotRequest::width),
- Codec.INT.optionalFieldOf("height", 0).forGetter(ScreenshotRequest::height),
- Codec.DOUBLE.optionalFieldOf("fov", 0.0).forGetter(ScreenshotRequest::fov),
- Codec.INT.optionalFieldOf("render_distance", 0).forGetter(ScreenshotRequest::renderDistance),
- Codec.INT.optionalFieldOf("ssaa", 0).forGetter(ScreenshotRequest::ssaa),
- Codec.INT.optionalFieldOf("biome_blend", -1).forGetter(ScreenshotRequest::biomeBlend)
- ).apply(instance, ScreenshotRequest::new));
-
- /** MSMP discovery schema. */
+ Codec.DOUBLE.listOf().optionalFieldOf("rotation").forGetter(ScreenshotRequest::rotation),
+ Codec.INT.optionalFieldOf("width").forGetter(ScreenshotRequest::width),
+ Codec.INT.optionalFieldOf("height").forGetter(ScreenshotRequest::height),
+ Codec.DOUBLE.optionalFieldOf("fov").forGetter(ScreenshotRequest::fov),
+ Codec.INT.optionalFieldOf("render_distance").forGetter(ScreenshotRequest::renderDistance),
+ Codec.INT.optionalFieldOf("ssaa").forGetter(ScreenshotRequest::ssaa),
+ Codec.INT.optionalFieldOf("biome_blend").forGetter(ScreenshotRequest::biomeBlend)
+ ).apply(i, ScreenshotRequest::new));
+
+ /**
+ * MSMP schema for {@link ScreenshotRequest}, used for protocol discovery.
+ */
public static final Schema SCHEMA = Schema.record(CODEC)
.withField("dimension", Schema.STRING_SCHEMA)
.withField("position", POSITION_SCHEMA)
- .withField("yaw", Schema.NUMBER_SCHEMA)
- .withField("pitch", Schema.NUMBER_SCHEMA)
+ .withField("rotation", ROTATION_SCHEMA)
.withField("width", Schema.INT_SCHEMA)
.withField("height", Schema.INT_SCHEMA)
.withField("fov", Schema.NUMBER_SCHEMA)
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java
index 6de4fb0..e07d835 100644
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java
@@ -6,27 +6,38 @@
/**
- * Result of the {@code msmp_vision:screenshot} method.
+ * Response payload for the {@code vision:screenshot} method.
*
- * @param image Base64 encoded image data.
- * @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.
+ * Example 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 Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
Codec.STRING.fieldOf("image").forGetter(ScreenshotResponse::image),
Codec.STRING.fieldOf("format").forGetter(ScreenshotResponse::format),
Codec.INT.fieldOf("width").forGetter(ScreenshotResponse::width),
Codec.INT.fieldOf("height").forGetter(ScreenshotResponse::height),
Codec.INT.fieldOf("render_time_ms").forGetter(ScreenshotResponse::renderTimeMs)
- ).apply(instance, ScreenshotResponse::new));
-
+ ).apply(i, ScreenshotResponse::new));
+
/**
* MSMP schema for {@link ScreenshotResponse}, used for protocol discovery.
*/
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java
new file mode 100644
index 0000000..dd71635
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java
@@ -0,0 +1,17 @@
+package dev.loat.msmp_vision.msmp.exceptions;
+
+/**
+ * Thrown when a method receives invalid or incomplete parameters.
+ *
+ * Used 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/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..7a55829 100644
--- a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
+++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
@@ -6,15 +6,14 @@
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.
@@ -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);
+ List position = request.position();
+ require(position.size() == 3, "'position' must contain exactly 3 elements [x, y, z], got " + position.size());
+
+ List rotation = request.rotation().orElse(List.of(0.0, 0.0));
+ require(rotation.size() == 2, "'rotation' must contain exactly 2 elements [yaw, pitch], got " + rotation.size());
+
+ require(position.stream().allMatch(Double::isFinite), "'position' must only contain finite numbers");
+ require(rotation.stream().allMatch(Double::isFinite), "'rotation' must only contain finite numbers");
+
+ int width = request.width().orElse(defaultsConfig.width);
+ int height = request.height().orElse(defaultsConfig.height);
+ double fov = request.fov().orElse(defaultsConfig.fov);
+ int distance = request.renderDistance().orElse(defaultsConfig.renderDistance);
+ int ssaa = request.ssaa().orElse(defaultsConfig.ssaa);
+ int blend = request.biomeBlend().orElse(defaultsConfig.biomeBlend);
+
+ require(width > 0, "'width' must be positive");
+ require(height > 0, "'height' must be positive");
+ require(distance > 0, "'render_distance' must be positive");
+ require(ssaa > 0, "'ssaa' must be positive");
+ require(blend >= 0, "'biome_blend' must not be negative");
+
+ 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, "'render_distance' 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);
require((long) width * ssaa * height * ssaa <= limitsConfig.maxRenderedPixels, "width*height*ssaa^2 exceeds " + limitsConfig.maxRenderedPixels + " pixels");
return new RenderSettings(
- request.position(),
- request.yaw(),
- request.pitch(),
+ List.copyOf(position),
+ rotation.get(0),
+ Math.max(-MAX_PITCH, Math.min(MAX_PITCH, rotation.get(1))),
width,
height,
fov,
@@ -87,7 +104,7 @@ public int renderHeight() {
private static void require(boolean condition, String message) {
if (!condition) {
- throw new IllegalArgumentException(message);
+ throw new InvalidParamsException(message);
}
}
}
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
index e1bbf2f..a1427c8 100644
--- a/src/main/resources/fabric.mod.json
+++ b/src/main/resources/fabric.mod.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
- "id": "msmp-vision-mod",
+ "id": "msmp-vision",
"version": "${version}",
"name": "MSMP Vision",
"description": "Extends the Minecraft Server Management Protocol (MSMP) by providing additional functions for taking and rendering screenshots entirely on the server-side.",
@@ -20,9 +20,7 @@
"depends": {
"fabricloader": ">=${loader_version}",
"fabric-api": "*",
- "minecraft": "^${minecraft_version}",
- "msmp-lib": "*",
- "config-lib": "*"
+ "minecraft": "^${minecraft_version}"
},
"custom": {
"mc-publish": {
From 8710beff600dbf637931bdf4e202413048f98ae8 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 20 Oct 2026 00:07:24 +0200
Subject: [PATCH 05/18] fix: sky
---
.../loat/msmp_vision/render/SkyPainter.java | 46 +++++++++++++------
.../world/EnvironmentSnapshot.java | 4 +-
.../msmp_vision/world/SnapshotCapture.java | 40 ++++++++++++++--
3 files changed, 73 insertions(+), 17 deletions(-)
diff --git a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
index 5db0284..fa1e316 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
@@ -8,13 +8,16 @@
/**
* Fills the framebuffer with a sky gradient, sun and moon. The horizon colour equals the fog colour so that
- * distant terrain blends seamlessly into the sky.
+ * distant terrain blends seamlessly into the sky; the colour overhead is the sky colour of the environment.
+ * Both already contain the time of day, so they are used as they are.
*/
public final class SkyPainter {
- private static final int ZENITH_DAY = 0x78A7FF;
- private static final float SUN_COS = 0.997f;
- private static final float MOON_COS = 0.9985f;
+ /** Distance of the horizon haze: sky blends from fog colour to sky colour over this much of {@code dir.y}. */
+ private static final float HAZE_HEIGHT = 0.45f;
+ /** Half edge length of the sun / moon quad, as {@code tan(angle)}; vanilla uses a 30 unit quad at 100 units. */
+ private static final float SUN_HALF = 0.15f;
+ private static final float MOON_HALF = 0.10f;
private SkyPainter() {}
@@ -31,10 +34,12 @@ public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot
int[] color = target.color();
Arrays.fill(target.depth(), 0f);
- float day = Math.max(environment.skyLightFactor(), 0.03f);
float[] horizon = rgb(environment.fogColor());
- float[] zenith = {((ZENITH_DAY >> 16) & 0xFF) * day, ((ZENITH_DAY >> 8) & 0xFF) * day, (ZENITH_DAY & 0xFF) * day};
- Vector3f sun = new Vector3f((float) -Math.sin(environment.sunAngleRadians()), (float) Math.cos(environment.sunAngleRadians()), 0).normalize();
+ float[] zenith = rgb(environment.skyColor());
+
+ double angle = environment.sunAngleRadians();
+ Vector3f sun = new Vector3f((float) -Math.sin(angle), (float) Math.cos(angle), 0).normalize();
+ Vector3f tangent = new Vector3f((float) Math.cos(angle), (float) Math.sin(angle), 0);
Vector3f dir = new Vector3f();
for (int y = 0; y < height; y++) {
@@ -45,22 +50,37 @@ public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot
.fma(ndcX * camera.tanHalfFov() * camera.aspect(), camera.right())
.fma(ndcY * camera.tanHalfFov(), camera.up())
.normalize();
- float t = (float) Math.sqrt(Math.max(dir.y, 0f));
+
+ float t = smoothstep(Math.max(dir.y, 0f) / HAZE_HEIGHT);
float r = horizon[0] + (zenith[0] - horizon[0]) * t;
float g = horizon[1] + (zenith[1] - horizon[1]) * t;
float b = horizon[2] + (zenith[2] - horizon[2]) * t;
- float sunDot = dir.dot(sun);
- if (sunDot > SUN_COS) {
- r = 255; g = 235; b = 150;
- } else if (-sunDot > MOON_COS) {
- r = 225; g = 225; b = 232;
+ if (dir.y > -0.02f) {
+ float d = dir.dot(sun);
+ if (d > 0.01f && inQuad(dir, tangent, d, SUN_HALF)) {
+ r = 255; g = 255; b = 255;
+ } else if (d < -0.01f && inQuad(dir, tangent, -d, MOON_HALF)) {
+ r = 225; g = 228; b = 238;
+ }
}
color[y * width + x] = 0xFF000000 | (clamp(r) << 16) | (clamp(g) << 8) | clamp(b);
}
}
}
+ /** Tests whether a view direction hits a square billboard facing the sun axis, projected onto the plane at distance 1. */
+ private static boolean inQuad(Vector3f dir, Vector3f tangent, float axisDot, float half) {
+ float u = dir.dot(tangent) / axisDot;
+ float v = dir.z / axisDot;
+ return Math.abs(u) < half && Math.abs(v) < half;
+ }
+
+ private static float smoothstep(float x) {
+ float t = Math.max(0f, Math.min(1f, x));
+ return t * t * (3f - 2f * t);
+ }
+
private static float[] rgb(int packed) {
return new float[]{(packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF};
}
diff --git a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java
index 1f11b27..9f389b6 100644
--- a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java
+++ b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java
@@ -5,12 +5,14 @@
*
* @param sunAngleRadians Sun angle in radians (0 = zenith).
* @param skyLightFactor Sky light multiplier, 0 (night) to 1 (day).
- * @param fogColor Fog colour as {@code 0xRRGGBB}.
+ * @param fogColor Fog colour as {@code 0xRRGGBB}; also the colour of the sky at the horizon.
+ * @param skyColor Sky colour as {@code 0xRRGGBB}; the colour of the sky overhead.
* @param hasSkyLight Whether the dimension has sky light.
*/
public record EnvironmentSnapshot(
double sunAngleRadians,
float skyLightFactor,
int fogColor,
+ int skyColor,
boolean hasSkyLight
) {}
diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
index a108624..6445841 100644
--- a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
+++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
@@ -1,5 +1,6 @@
package dev.loat.msmp_vision.world;
+import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.render.Camera;
import dev.loat.msmp_vision.render.RenderSettings;
import net.minecraft.core.SectionPos;
@@ -12,13 +13,16 @@
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.lighting.LevelLightEngine;
import net.minecraft.world.phys.Vec3;
-import org.joml.Vector3f;
+import org.joml.Vector3fc;
/**
* Copies the chunk sections around the camera into a {@link WorldSnapshot}. Must run on the server thread.
*/
public final class SnapshotCapture {
+ private static final int FALLBACK_FOG = 0xC0D8FF;
+ private static final int FALLBACK_SKY = 0x78A7FF;
+
private SnapshotCapture() {}
/**
@@ -31,11 +35,22 @@ private SnapshotCapture() {}
*/
public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings) {
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);
+
+ Object rawFog = level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position);
+ Object rawSky = level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position);
+ int fogColor = toRgb(rawFog, FALLBACK_FOG);
+ int skyColor = toRgb(rawSky, FALLBACK_SKY);
+ // TODO remove once the sky colours are confirmed to be correct
+ Logger.info("[diagnostic] fog raw=%s (%s) -> #%06X, sky raw=%s (%s) -> #%06X".formatted(
+ rawFog, rawFog == null ? null : rawFog.getClass().getName(), fogColor,
+ rawSky, rawSky == null ? null : rawSky.getClass().getName(), skyColor
+ ));
+
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,
+ fogColor,
+ skyColor,
level.dimensionType().hasSkyLight()
);
WorldSnapshot snapshot = new WorldSnapshot(environment);
@@ -75,6 +90,25 @@ public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSett
return snapshot;
}
+ /**
+ * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts RGB vectors (components 0..1)
+ * as well as packed integer colours (alpha is ignored) and clamps everything so nothing can overflow
+ * into a neighbouring channel.
+ */
+ private static int toRgb(Object raw, int fallback) {
+ if (raw instanceof Vector3fc vector) {
+ return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z());
+ }
+ if (raw instanceof Integer packed) {
+ return packed & 0xFFFFFF;
+ }
+ return fallback;
+ }
+
+ private static int channel(float unit) {
+ return Mth.clamp((int) (unit * 255.0f + 0.5f), 0, 255);
+ }
+
private static boolean isRelevant(Camera camera, RenderSettings settings, double radius, int cx, int sy, int cz) {
double dx = (cx << 4) + 8 - settings.position().get(0);
double dy = (sy << 4) + 8 - settings.position().get(1);
From 606499bef20003bbca4d69e492bbd2e3388a76a0 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 20 Oct 2026 19:32:10 +0200
Subject: [PATCH 06/18] fix: refactor config
---
.../java/dev/loat/msmp_vision/VisionService.java | 2 +-
.../config/files/MSMPVisionConfigFile.java | 14 +++++---------
.../RenderConfig.java} | 10 ++++++++--
.../config/files/threads/ThreadsConfig.java | 13 -------------
.../msmp/endpoints/screenshot/Screenshot.java | 3 ++-
.../dev/loat/msmp_vision/render/SceneRenderer.java | 6 +++---
6 files changed, 19 insertions(+), 29 deletions(-)
rename src/main/java/dev/loat/msmp_vision/config/files/{rendering/RenderingConfig.java => render/RenderConfig.java} (66%)
delete mode 100644 src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java
diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java
index 85984f1..129683c 100644
--- a/src/main/java/dev/loat/msmp_vision/VisionService.java
+++ b/src/main/java/dev/loat/msmp_vision/VisionService.java
@@ -54,7 +54,7 @@ public void start() {
Path root = FabricLoader.getInstance().getConfigDir().resolve("msmp/vision/client_assets");
AssetManager assets = new AssetManager(root, FabricLoader.getInstance().getRawGameVersion());
TextureStore textures = new TextureStore(assets);
- RenderExecutor executor = new RenderExecutor(config.threads.render);
+ RenderExecutor executor = new RenderExecutor(config.render.threads);
SceneRenderer renderer = new SceneRenderer(assets, textures, executor);
CompletableFuture ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets));
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
index 9a5fd7d..e594b37 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
@@ -3,8 +3,7 @@
import dev.loat.config_lib.annotation.Annotation;
import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig;
import dev.loat.msmp_vision.config.files.limits.LimitsConfig;
-import dev.loat.msmp_vision.config.files.rendering.RenderingConfig;
-import dev.loat.msmp_vision.config.files.threads.ThreadsConfig;
+import dev.loat.msmp_vision.config.files.render.RenderConfig;
@Annotation.Comment("""
@@ -14,8 +13,9 @@ public class MSMPVisionConfigFile {
private MSMPVisionConfigFile() {}
- @Annotation.Comment("Thread settings of the renderer.")
- public ThreadsConfig threads = new ThreadsConfig();
+ @Annotation.Comment("Maximum time in seconds a single screenshot request may take.")
+ @Annotation.Key("request-timeout-seconds")
+ public int requestTimeoutSeconds = 30;
@Annotation.Comment("Values used when a request does not specify a setting.")
public DefaultsConfig defaults = new DefaultsConfig();
@@ -24,9 +24,5 @@ private MSMPVisionConfigFile() {}
public LimitsConfig limits = new LimitsConfig();
@Annotation.Comment("Tuning options of the rasterizer.")
- public RenderingConfig rendering = new RenderingConfig();
-
- @Annotation.Comment("Maximum time in seconds a single screenshot request may take.")
- @Annotation.Key("request-timeout-seconds")
- public int requestTimeoutSeconds = 30;
+ public RenderConfig render = new RenderConfig();
}
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
similarity index 66%
rename from src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java
rename to src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
index 7e6b979..33e3863 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
@@ -1,9 +1,15 @@
-package dev.loat.msmp_vision.config.files.rendering;
+package dev.loat.msmp_vision.config.files.render;
import dev.loat.config_lib.annotation.Annotation;
-public class RenderingConfig {
+public class RenderConfig {
+
+ @Annotation.Comment("""
+ Number of render worker threads (meshing and rasterization).
+ 0 = automatic (available processors - 1, at least 1).
+ """)
+ public int threads = 0;
@Annotation.Comment("Edge length of a raster tile in pixels. One tile is one parallel work unit.")
@Annotation.Key("tile-size")
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java
deleted file mode 100644
index e67163e..0000000
--- a/src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package dev.loat.msmp_vision.config.files.threads;
-
-import dev.loat.config_lib.annotation.Annotation;
-
-
-public class ThreadsConfig {
-
- @Annotation.Comment("""
- Number of render worker threads (meshing and rasterization).
- 0 = automatic (available processors - 1, at least 1).
- """)
- public int render = 0;
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
index fdd125b..8a12589 100644
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
@@ -2,6 +2,7 @@
import dev.loat.msmp.MSMPNamespace;
import dev.loat.msmp_vision.VisionService;
+import dev.loat.msmp_vision.config.Config;
import dev.loat.msmp_vision.logging.RPCConnectionLogger;
@@ -61,7 +62,7 @@ public static void register(MSMPNamespace namespace, VisionService service) {
try {
ScreenshotResponse response = service.screenshot(server, params);
RPCConnectionLogger.info(client.connectionId(),
- "vision:screenshot - rendered %dx%d in %d ms".formatted(response.width(), response.height(), response.renderTimeMs()));
+ "vision:screenshot - rendered %dx%d in %dms on %d threads".formatted(response.width(), response.height(), response.renderTimeMs(), Config.getConfig().render.threads));
return response;
} catch (RuntimeException e) {
RPCConnectionLogger.warning(client.connectionId(), "vision:screenshot - " + e.getMessage());
diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
index aabe3e7..1e31928 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
@@ -62,7 +62,7 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s
long timeout = config.requestTimeoutSeconds;
int width = settings.renderWidth();
int height = settings.renderHeight();
- boolean cull = config.rendering.backfaceCulling;
+ boolean cull = config.render.backfaceCulling;
List> jobs = new ArrayList<>();
for (SectionSnapshot section : world.sections()) {
@@ -89,8 +89,8 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s
SkyPainter.paint(framebuffer, camera, world.environment());
float fogEnd = settings.renderDistance();
- Fog fog = Fog.of((float) (fogEnd * config.rendering.fogStartFactor), fogEnd, world.environment().fogColor());
- new TileRasterizer(this.executor, config.rendering.tileSize, timeout)
+ Fog fog = Fog.of((float) (fogEnd * config.render.fogStartFactor), fogEnd, world.environment().fogColor());
+ new TileRasterizer(this.executor, config.render.tileSize, timeout)
.rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog);
return framebuffer.toImage(settings.ssaa());
}
From 21826bfea9ac898f478519705ed1bacd6a89c41b Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 20 Oct 2026 19:35:09 +0200
Subject: [PATCH 07/18] doc: fix config
---
README.md | 24 ++++++++++++------------
1 file changed, 12 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index fb9d7fe..84a2577 100644
--- a/README.md
+++ b/README.md
@@ -24,13 +24,7 @@ On first start, the mod generates a configuration file at `/con
```yaml
# Main configuration file for MSMP Vision.
-
-# Thread settings of the renderer.
-threads:
- # Number of render worker threads (meshing and rasterization).
- # 0 = automatic (available processors - 1, at least 1).
- # @default: 0
- render: 0
+
# Values used when a request does not specify a setting.
defaults:
# Output width in pixels.
@@ -42,12 +36,14 @@ defaults:
# Vertical field of view in degrees.
# @default: 70.0
fov: 70.0
- # Render distance in blocks.
- # @default: 64
- render-distance: 64
# Supersampling factor (1 = off).
# @default: 1
ssaa: 1
+ # @deprecated: This key is no longer used and can be removed safely.
+ biomeBlend: 1
+ # Render distance in blocks.
+ # @default: 64
+ render-distance: 64
# Biome tint blend radius in blocks (0 = off).
# @default: 1
biome-blend: 1
@@ -61,7 +57,7 @@ limits:
max-height: 1080
# Maximum render distance in blocks.
# @default: 128
- max-render-distance: 128
+ max-render-distance: 256
# Maximum supersampling factor.
# @default: 3
max-ssaa: 3
@@ -75,7 +71,11 @@ limits:
# @default: 110.0
max-fov: 110.0
# Tuning options of the rasterizer.
-rendering:
+render:
+ # Number of render worker threads (meshing and rasterization).
+ # 0 = automatic (available processors - 1, at least 1).
+ # @default: 0
+ threads: 0
# Edge length of a raster tile in pixels. One tile is one parallel work unit.
# @default: 64
tile-size: 64
From c01ca4d7490ea0298569bc5c54ba294ca17b3730 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:56:15 +0200
Subject: [PATCH 08/18] fix: chunks not rendered
---
.../dev/loat/msmp_vision/VisionService.java | 13 +-
.../config/files/MSMPVisionConfigFile.java | 12 +-
.../config/files/chunks/ChunksConfig.java | 33 ++++
.../dev/loat/msmp_vision/render/Camera.java | 15 ++
.../msmp_vision/world/SnapshotCapture.java | 142 ++++++++++++------
5 files changed, 162 insertions(+), 53 deletions(-)
create mode 100644 src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java
index 129683c..2951397 100644
--- a/src/main/java/dev/loat/msmp_vision/VisionService.java
+++ b/src/main/java/dev/loat/msmp_vision/VisionService.java
@@ -105,7 +105,7 @@ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest r
long start = System.nanoTime();
Camera camera = new Camera(settings);
- WorldSnapshot snapshot = this.capture(server, level, camera, settings, config.requestTimeoutSeconds);
+ WorldSnapshot snapshot = this.capture(server, level, camera, settings, config);
BufferedImage image = current.renderer().render(snapshot, camera, settings, config);
String encoded = ImageEncoder.toBase64Png(image);
int elapsed = (int) ((System.nanoTime() - start) / 1_000_000L);
@@ -113,12 +113,17 @@ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest r
return new ScreenshotResponse(encoded, "png", image.getWidth(), image.getHeight(), elapsed);
}
- private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, long timeoutSeconds) {
+ /**
+ * Captures the world on the server thread. MSMP handlers already run there, in which case the capture
+ * (including loading missing chunks) is done directly; otherwise it is scheduled on the server thread.
+ */
+ private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
if (server.isSameThread()) {
- return SnapshotCapture.capture(level, camera, settings);
+ return SnapshotCapture.capture(level, camera, settings, config.chunks);
}
+ long timeoutSeconds = config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds;
try {
- return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings), server)
+ return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings, config.chunks), server)
.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("The server thread did not respond in time.");
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
index e594b37..094bcf3 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java
@@ -1,6 +1,7 @@
package dev.loat.msmp_vision.config.files;
import dev.loat.config_lib.annotation.Annotation;
+import dev.loat.msmp_vision.config.files.chunks.ChunksConfig;
import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig;
import dev.loat.msmp_vision.config.files.limits.LimitsConfig;
import dev.loat.msmp_vision.config.files.render.RenderConfig;
@@ -13,10 +14,6 @@ public class MSMPVisionConfigFile {
private MSMPVisionConfigFile() {}
- @Annotation.Comment("Maximum time in seconds a single screenshot request may take.")
- @Annotation.Key("request-timeout-seconds")
- public int requestTimeoutSeconds = 30;
-
@Annotation.Comment("Values used when a request does not specify a setting.")
public DefaultsConfig defaults = new DefaultsConfig();
@@ -25,4 +22,11 @@ private MSMPVisionConfigFile() {}
@Annotation.Comment("Tuning options of the rasterizer.")
public RenderConfig render = new RenderConfig();
+
+ @Annotation.Comment("Settings for chunks that are not loaded when a screenshot is requested.")
+ public ChunksConfig chunks = new ChunksConfig();
+
+ @Annotation.Comment("Maximum time in seconds a single screenshot request may take (without chunk loading).")
+ @Annotation.Key("request-timeout-seconds")
+ public int requestTimeoutSeconds = 30;
}
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
new file mode 100644
index 0000000..0081f18
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
@@ -0,0 +1,33 @@
+package dev.loat.msmp_vision.config.files.chunks;
+
+import dev.loat.config_lib.annotation.Annotation;
+
+
+public class ChunksConfig {
+
+ @Annotation.Comment("""
+ How chunks that are not loaded are handled while a screenshot is taken.
+ NONE: only chunks that are already loaded (ex. by a player) are rendered.
+ LOAD: missing chunks are loaded from disk, chunks that do not exist yet are generated.
+ """)
+ public Mode mode = Mode.LOAD;
+
+ @Annotation.Comment("""
+ Maximum number of chunks that may be loaded (or generated) for a single request.
+ Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered.
+ """)
+ @Annotation.Key("max-chunk-loads")
+ public int maxChunkLoads = 512;
+
+ @Annotation.Comment("""
+ Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time,
+ keep it well below the server's watchdog (max-tick-time, 60s by default). Remaining chunks are rendered as empty space.
+ """)
+ @Annotation.Key("load-timeout-seconds")
+ public int loadTimeoutSeconds = 10;
+
+ public static enum Mode {
+ NONE,
+ LOAD
+ }
+}
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..aad7abd 100644
--- a/src/main/java/dev/loat/msmp_vision/render/Camera.java
+++ b/src/main/java/dev/loat/msmp_vision/render/Camera.java
@@ -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/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
index 6445841..bf2955d 100644
--- a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
+++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
@@ -1,107 +1,159 @@
package dev.loat.msmp_vision.world;
+import dev.loat.msmp_vision.config.files.chunks.ChunksConfig;
import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.render.Camera;
import dev.loat.msmp_vision.render.RenderSettings;
import net.minecraft.core.SectionPos;
+import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.Mth;
import net.minecraft.world.attribute.EnvironmentAttributes;
import net.minecraft.world.level.LightLayer;
+import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.DataLayer;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
+import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.lighting.LevelLightEngine;
import net.minecraft.world.phys.Vec3;
import org.joml.Vector3fc;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
/**
* Copies the chunk sections around the camera into a {@link WorldSnapshot}. Must run on the server thread.
+ *
+ * Chunks 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));
-
- Object rawFog = level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position);
- Object rawSky = level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position);
- int fogColor = toRgb(rawFog, FALLBACK_FOG);
- int skyColor = toRgb(rawSky, FALLBACK_SKY);
- // TODO remove once the sky colours are confirmed to be correct
- Logger.info("[diagnostic] fog raw=%s (%s) -> #%06X, sky raw=%s (%s) -> #%06X".formatted(
- rawFog, rawFog == null ? null : rawFog.getClass().getName(), fogColor,
- rawSky, rawSky == null ? null : rawSky.getClass().getName(), skyColor
- ));
-
EnvironmentSnapshot environment = new EnvironmentSnapshot(
level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD,
level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position),
- fogColor,
- skyColor,
+ 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();
+ 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 deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds));
+ int loaded = 0;
+ int skipped = 0;
+
+ for (Column column : visibleColumns(level, camera, settings, radius)) {
+ LevelChunk chunk = cache.getChunkNow(column.x(), column.z());
+ if (chunk == null && chunks.mode == ChunksConfig.Mode.LOAD) {
+ if (loaded >= chunks.maxChunkLoads || System.nanoTime() >= deadline) {
+ skipped++;
continue;
}
- LevelChunkSection[] sections = chunk.getSections();
- for (int i = 0; i < sections.length; i++) {
- int sy = minSectionY + i;
- if (!isRelevant(camera, settings, radius, cx, sy, cz)) {
- continue;
- }
- LevelChunkSection section = sections[i];
- SectionPos pos = SectionPos.of(cx, sy, cz);
- snapshot.put(new SectionSnapshot(
- cx, sy, cz,
- section.hasOnlyAir() ? null : section.getStates().copy(),
- section.getBiomes().copy(),
- environment.hasSkyLight() ? copyLayer(light, LightLayer.SKY, pos) : null,
- copyLayer(light, LightLayer.BLOCK, pos)
- ));
+ ChunkAccess access = cache.getChunk(column.x(), column.z(), ChunkStatus.FULL, true);
+ loaded++;
+ if (access instanceof LevelChunk levelChunk) {
+ chunk = levelChunk;
}
}
+ if (chunk != null) {
+ copyColumn(snapshot, chunk, column, camera, settings, radius, light, minSectionY, environment.hasSkyLight());
+ }
+ }
+
+ if (skipped > 0) {
+ Logger.warning("Chunk loading limit reached, %d chunk(s) were not loaded and are missing in the screenshot (loaded %d)."
+ .formatted(skipped, loaded));
}
return snapshot;
}
+ /** All chunk columns inside the render radius and the view frustum, nearest first. */
+ private static List visibleColumns(ServerLevel level, Camera camera, RenderSettings settings, double radius) {
+ double px = settings.position().get(0);
+ double pz = settings.position().get(2);
+ int minChunkX = Mth.floor(px - radius) >> 4;
+ int maxChunkX = Mth.floor(px + radius) >> 4;
+ int minChunkZ = Mth.floor(pz - radius) >> 4;
+ int maxChunkZ = Mth.floor(pz + radius) >> 4;
+ int minY = level.getMinY();
+ int maxY = minY + level.getHeight();
+ double reach = radius + 12; // half diagonal of a chunk
+
+ List columns = new ArrayList<>();
+ for (int cz = minChunkZ; cz <= maxChunkZ; cz++) {
+ for (int cx = minChunkX; cx <= maxChunkX; cx++) {
+ double dx = (cx << 4) + 8 - px;
+ double dz = (cz << 4) + 8 - pz;
+ double distanceSquared = dx * dx + dz * dz;
+ if (distanceSquared > reach * reach || !camera.intersectsColumn(cx, cz, minY, maxY)) {
+ continue;
+ }
+ columns.add(new Column(cx, cz, distanceSquared));
+ }
+ }
+ columns.sort(Comparator.comparingDouble(Column::distanceSquared));
+ return columns;
+ }
+
+ private static void copyColumn(WorldSnapshot snapshot, LevelChunk chunk, Column column, Camera camera, RenderSettings settings,
+ double radius, LevelLightEngine light, int minSectionY, boolean hasSkyLight) {
+ LevelChunkSection[] sections = chunk.getSections();
+ for (int i = 0; i < sections.length; i++) {
+ int sy = minSectionY + i;
+ if (!isRelevant(camera, settings, radius, column.x(), sy, column.z())) {
+ continue;
+ }
+ LevelChunkSection section = sections[i];
+ SectionPos pos = SectionPos.of(column.x(), sy, column.z());
+ snapshot.put(new SectionSnapshot(
+ column.x(), sy, column.z(),
+ section.hasOnlyAir() ? null : section.getStates().copy(),
+ section.getBiomes().copy(),
+ hasSkyLight ? copyLayer(light, LightLayer.SKY, pos) : null,
+ copyLayer(light, LightLayer.BLOCK, pos)
+ ));
+ }
+ }
+
/**
- * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts RGB vectors (components 0..1)
- * as well as packed integer colours (alpha is ignored) and clamps everything so nothing can overflow
- * into a neighbouring channel.
+ * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts packed integer colours (alpha is ignored)
+ * as well as RGB vectors (components 0..1) and clamps everything so nothing can overflow into a neighbouring channel.
*/
private static int toRgb(Object raw, int fallback) {
- if (raw instanceof Vector3fc vector) {
- return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z());
- }
if (raw instanceof Integer packed) {
return packed & 0xFFFFFF;
}
+ if (raw instanceof Vector3fc vector) {
+ return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z());
+ }
return fallback;
}
From 10752eea2fd4f2712f0120a524537fb033bc0321 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 21 Oct 2026 00:56:43 +0200
Subject: [PATCH 09/18] Update README.md
---
README.md | 101 ++++++++++++++++++++++++++++++++----------------------
1 file changed, 60 insertions(+), 41 deletions(-)
diff --git a/README.md b/README.md
index 84a2577..2744354 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,37 @@
# MSMP Vision
-
+
A server-side Fabric mod that extends the [Minecraft Server Management Protocol](https://minecraft.wiki/w/Minecraft_Server_Management_Protocol) (MSMP) by providing a function for taking screenshots entirely on the server. No client and no GPU are required, the image is rendered by a multi-threaded software rasterizer.
-
+
This mod is designed for tooling, dashboards, automation systems, external monitoring tools, and integrations that need a picture of the world without running a game client.
-
-
+
+
## Installation
-
+
1. Download the mod `.jar` and place it in your server's `mods/` folder (requires [Fabric API](https://modrinth.com/mod/fabric-api)).
2. Enable the Management Server in `server.properties`:
-```properties
+ ```properties
management-server-enabled=true
-```
+ ```
3. Start the server. The Management Server will listen on `localhost:25576` by default.
+
On the first start the mod downloads the official client `.jar` of the running Minecraft version from Mojang and extracts the block models and textures to `/config/msmp/vision/client_assets/`. No Mojang assets are shipped with the mod. Until this is finished, screenshot requests fail with *"Client assets are still being prepared"*.
-
-
+
+
## Configuration
-
+
On first start, the mod generates a configuration file at `/config/msmp/vision/config.yml`:
-
+
```yaml
# Main configuration file for MSMP Vision.
+# Thread settings of the renderer.
+threads:
+ # Number of render worker threads (meshing and rasterization).
+ # 0 = automatic (available processors - 1, at least 1).
+ # @default: 0
+ render: 0
# Values used when a request does not specify a setting.
defaults:
# Output width in pixels.
@@ -36,14 +43,12 @@ defaults:
# Vertical field of view in degrees.
# @default: 70.0
fov: 70.0
- # Supersampling factor (1 = off).
- # @default: 1
- ssaa: 1
- # @deprecated: This key is no longer used and can be removed safely.
- biomeBlend: 1
# Render distance in blocks.
# @default: 64
render-distance: 64
+ # Supersampling factor (1 = off).
+ # @default: 1
+ ssaa: 1
# Biome tint blend radius in blocks (0 = off).
# @default: 1
biome-blend: 1
@@ -57,7 +62,7 @@ limits:
max-height: 1080
# Maximum render distance in blocks.
# @default: 128
- max-render-distance: 256
+ max-render-distance: 128
# Maximum supersampling factor.
# @default: 3
max-ssaa: 3
@@ -71,11 +76,7 @@ limits:
# @default: 110.0
max-fov: 110.0
# Tuning options of the rasterizer.
-render:
- # Number of render worker threads (meshing and rasterization).
- # 0 = automatic (available processors - 1, at least 1).
- # @default: 0
- threads: 0
+rendering:
# Edge length of a raster tile in pixels. One tile is one parallel work unit.
# @default: 64
tile-size: 64
@@ -85,26 +86,44 @@ render:
# Skip back-facing triangles. Disable only for debugging.
# @default: true
backface-culling: true
-# Maximum time in seconds a single screenshot request may take.
+# Settings for chunks that are not loaded when a screenshot is requested.
+chunks:
+ # How chunks that are not loaded are handled while a screenshot is taken.
+ # NONE: only chunks that are already loaded (ex. by a player) are rendered.
+ # LOAD: missing chunks are loaded from disk, chunks that do not exist yet are generated.
+ # @possible: NONE | LOAD
+ # @default: 'LOAD'
+ mode: LOAD
+ # Maximum number of chunks that may be loaded (or generated) for a single request.
+ # Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered.
+ # @default: 512
+ max-chunk-loads: 512
+ # Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time,
+ # keep it well below the server's watchdog (max-tick-time, 60s by default). Remaining chunks are rendered as empty space.
+ # @default: 10
+ load-timeout-seconds: 10
+# Maximum time in seconds a single screenshot request may take (without chunk loading).
# @default: 30
request-timeout-seconds: 30
```
-
+
`defaults` are used for every optional parameter a request omits. Requests that exceed a value in `limits` are rejected instead of being clamped. Rendering cost grows with `width * height * ssaa^2` and with the render distance, so lower the limits on weak servers.
-
-
+
+**Chunk loading:** With `chunks.mode: LOAD` (default) the mod loads the chunks inside the camera's view on demand, so screenshots also work where no player is. Chunks that were never generated are generated, which can take a while and creates world data. Use `NONE` if screenshots must never change the world. MSMP calls run on the **server thread**: the server does not tick while a screenshot is being loaded and rendered, so keep `load-timeout-seconds` and `request-timeout-seconds` well below the watchdog (`max-tick-time`, 60 s by default).
+
+
## RPC Methods
-
+
The mod currently provides the following MSMP RPC method. It is also automatically discoverable through the standard `rpc.discover` MSMP endpoint.
-
+
| Method | Description |
| ------------------- | ------------------------------------------------------------------- |
| `vision:screenshot` | Renders a server-side screenshot and returns it as a Base64 PNG |
-
+
### `vision:screenshot`
-
+
Vectors are transferred as arrays, exactly like in the `entity` namespace.
-
+
| Parameter | Type | Required | Description |
| ----------------- | ------------- | -------- | ---------------------------------------------------------------------------------- |
| `position` | `[x, y, z]` | yes | Camera position |
@@ -116,9 +135,9 @@ Vectors are transferred as arrays, exactly like in the `entity` namespace.
| `render_distance` | int | no | Render distance in blocks. Default from config |
| `ssaa` | int | no | Supersampling factor, `1` = off. Default from config |
| `biome_blend` | int | no | Biome tint blend radius in blocks, `0` = off. Default from config |
-
+
Example request:
-
+
```json
{
"jsonrpc": "2.0",
@@ -132,9 +151,9 @@ Example request:
}]
}
```
-
+
Example response:
-
+
```json
{
"jsonrpc": "2.0",
@@ -148,12 +167,12 @@ Example response:
}
}
```
-
-Only chunks that are currently loaded are rendered; unloaded areas show up as empty space.
-
+
+Chunks that are not loaded are loaded on demand (see `chunks` in the config). Whatever cannot be loaded within the limits shows up as empty space.
+
> If you want more methods or parameters for other purposes, please [open an issue](https://github.com/MinecraftPlayground/msmp-vision-mod/issues/new?template=new_method_or_parameter_suggestion.yml)
-
-
+
+
## License
-
+
[LGPL-3.0](LICENSE)
From 10d2559a338bb0e9b23efa872a6149d671039fc8 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:17:33 +0200
Subject: [PATCH 10/18] feat: refactor config
---
README.md | 26 +++++++++----------
.../config/files/chunks/ChunksConfig.java | 2 +-
.../config/files/defaults/DefaultsConfig.java | 4 +--
.../config/files/limits/LimitsConfig.java | 4 +--
.../config/files/render/RenderConfig.java | 4 +--
.../msmp_vision/render/RenderSettings.java | 2 +-
.../msmp_vision/world/SnapshotCapture.java | 24 ++++++++++++-----
7 files changed, 38 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index 2744354..650e6b3 100644
--- a/README.md
+++ b/README.md
@@ -43,9 +43,9 @@ defaults:
# Vertical field of view in degrees.
# @default: 70.0
fov: 70.0
- # Render distance in blocks.
- # @default: 64
- render-distance: 64
+ # Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings.
+ # @default: 4
+ render-distance-chunks: 4
# Supersampling factor (1 = off).
# @default: 1
ssaa: 1
@@ -60,9 +60,9 @@ limits:
# Maximum output height.
# @default: 1080
max-height: 1080
- # Maximum render distance in blocks.
- # @default: 128
- max-render-distance: 128
+ # Maximum render distance in chunks (1 chunk = 16 blocks).
+ # @default: 8
+ max-render-distance-chunks: 8
# Maximum supersampling factor.
# @default: 3
max-ssaa: 3
@@ -80,9 +80,9 @@ rendering:
# Edge length of a raster tile in pixels. One tile is one parallel work unit.
# @default: 64
tile-size: 64
- # Fog starts at this fraction of the render distance.
- # @default: 0.75
- fog-start-factor: 0.75
+ # Fog starts at this fraction of the render distance. Minecraft itself only fogs the last ~10%.
+ # @default: 0.9
+ fog-start-factor: 0.9
# Skip back-facing triangles. Disable only for debugging.
# @default: true
backface-culling: true
@@ -96,8 +96,8 @@ chunks:
mode: LOAD
# Maximum number of chunks that may be loaded (or generated) for a single request.
# Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered.
- # @default: 512
- max-chunk-loads: 512
+ # @default: 2048
+ max-chunk-loads: 2048
# Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time,
# keep it well below the server's watchdog (max-tick-time, 60s by default). Remaining chunks are rendered as empty space.
# @default: 10
@@ -107,7 +107,7 @@ chunks:
request-timeout-seconds: 30
```
-`defaults` are used for every optional parameter a request omits. Requests that exceed a value in `limits` are rejected instead of being clamped. Rendering cost grows with `width * height * ssaa^2` and with the render distance, so lower the limits on weak servers.
+`defaults` are used for every optional parameter a request omits. Requests that exceed a value in `limits` are rejected instead of being clamped. Rendering cost grows with `width * height * ssaa^2` and with the square of the render distance (a render distance of 16 chunks can mean up to ~1000 chunks in view, so raise `chunks.max-chunk-loads` accordingly), so lower the limits on weak servers.
**Chunk loading:** With `chunks.mode: LOAD` (default) the mod loads the chunks inside the camera's view on demand, so screenshots also work where no player is. Chunks that were never generated are generated, which can take a while and creates world data. Use `NONE` if screenshots must never change the world. MSMP calls run on the **server thread**: the server does not tick while a screenshot is being loaded and rendered, so keep `load-timeout-seconds` and `request-timeout-seconds` well below the watchdog (`max-tick-time`, 60 s by default).
@@ -132,7 +132,7 @@ Vectors are transferred as arrays, exactly like in the `entity` namespace.
| `width` | int | no | Output width in pixels. Default from config |
| `height` | int | no | Output height in pixels. Default from config |
| `fov` | number | no | Vertical field of view in degrees. Default from config |
-| `render_distance` | int | no | Render distance in blocks. Default from config |
+| `render_distance` | int | no | Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings. Default from config |
| `ssaa` | int | no | Supersampling factor, `1` = off. Default from config |
| `biome_blend` | int | no | Biome tint blend radius in blocks, `0` = off. Default from config |
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
index 0081f18..5265836 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
@@ -17,7 +17,7 @@ Maximum number of chunks that may be loaded (or generated) for a single request.
Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered.
""")
@Annotation.Key("max-chunk-loads")
- public int maxChunkLoads = 512;
+ public int maxChunkLoads = 2048;
@Annotation.Comment("""
Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time,
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
index c7aabf2..d593b4d 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
@@ -14,9 +14,9 @@ public class DefaultsConfig {
@Annotation.Comment("Vertical field of view in degrees.")
public double fov = 70.0;
- @Annotation.Comment("Render distance in blocks.")
+ @Annotation.Comment("Render distance in chunks.")
@Annotation.Key("render-distance")
- public int renderDistance = 64;
+ public int renderDistance = 4;
@Annotation.Comment("Supersampling factor (1 = off).")
public int ssaa = 1;
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
index 9aac099..8c9f29e 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
@@ -13,9 +13,9 @@ public class LimitsConfig {
@Annotation.Key("max-height")
public int maxHeight = 1080;
- @Annotation.Comment("Maximum render distance in blocks.")
+ @Annotation.Comment("Maximum render distance in chunks.")
@Annotation.Key("max-render-distance")
- public int maxRenderDistance = 128;
+ public int maxRenderDistance = 32;
@Annotation.Comment("Maximum supersampling factor.")
@Annotation.Key("max-ssaa")
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
index 33e3863..b4dbf88 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
@@ -15,9 +15,9 @@ Number of render worker threads (meshing and rasterization).
@Annotation.Key("tile-size")
public int tileSize = 64;
- @Annotation.Comment("Fog starts at this fraction of the render distance.")
+ @Annotation.Comment("Fog starts at this fraction of the render distance. Minecraft itself only fogs the last ~10%.")
@Annotation.Key("fog-start-factor")
- public double fogStartFactor = 0.75;
+ public double fogStartFactor = 0.9;
@Annotation.Comment("Skip back-facing triangles. Disable only for debugging.")
@Annotation.Key("backface-culling")
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 7a55829..29a1854 100644
--- a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
+++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
@@ -19,7 +19,7 @@
* @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.
* @param ssaa Supersampling factor (1 = off).
* @param biomeBlend Biome tint blend radius in blocks.
*/
diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
index bf2955d..b3425f3 100644
--- a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
+++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
@@ -66,13 +66,18 @@ public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSett
LevelLightEngine light = level.getLightEngine();
int minSectionY = level.getMinSectionY();
- long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds));
+ long start = System.nanoTime();
+ long deadline = start + TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds));
+ List columns = visibleColumns(level, camera, settings, radius);
+ int resident = 0;
int loaded = 0;
int skipped = 0;
- for (Column column : visibleColumns(level, camera, settings, radius)) {
+ for (Column column : columns) {
LevelChunk chunk = cache.getChunkNow(column.x(), column.z());
- if (chunk == null && chunks.mode == ChunksConfig.Mode.LOAD) {
+ if (chunk != null) {
+ resident++;
+ } else if (chunks.mode == ChunksConfig.Mode.LOAD) {
if (loaded >= chunks.maxChunkLoads || System.nanoTime() >= deadline) {
skipped++;
continue;
@@ -88,9 +93,11 @@ public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSett
}
}
+ Logger.info("Captured %d section(s) from %d chunk column(s) in view (%d already loaded, %d newly loaded, %d skipped) in %d ms."
+ .formatted(snapshot.sections().size(), columns.size(), resident, loaded, skipped, (System.nanoTime() - start) / 1_000_000L));
if (skipped > 0) {
- Logger.warning("Chunk loading limit reached, %d chunk(s) were not loaded and are missing in the screenshot (loaded %d)."
- .formatted(skipped, loaded));
+ Logger.warning("Chunk loading limit reached: %d chunk(s) were not loaded and are missing in the screenshot. Raise 'chunks.max-chunk-loads' or 'chunks.load-timeout-seconds', or lower the render distance."
+ .formatted(skipped));
}
return snapshot;
}
@@ -103,8 +110,11 @@ private static List visibleColumns(ServerLevel level, Camera camera, Ren
int maxChunkX = Mth.floor(px + radius) >> 4;
int minChunkZ = Mth.floor(pz - radius) >> 4;
int maxChunkZ = Mth.floor(pz + radius) >> 4;
- int minY = level.getMinY();
- int maxY = minY + level.getHeight();
+ // Only sections within the render radius are ever used, so the column test only needs that height range.
+ // Testing the full world height would let the tilted frustum planes pick up many columns that can never be seen.
+ double py = settings.position().get(1);
+ int minY = Math.max(level.getMinY(), Mth.floor(py - radius));
+ int maxY = Math.min(level.getMinY() + level.getHeight(), Mth.ceil(py + radius));
double reach = radius + 12; // half diagonal of a chunk
List columns = new ArrayList<>();
From a2df0fba8234c58c6990bfabbb77d348552d713a Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 21 Oct 2026 12:16:48 +0200
Subject: [PATCH 11/18] debug
---
.../loat/msmp_vision/mesh/SectionMesher.java | 20 +++++++++++++++++--
.../msmp/endpoints/screenshot/Screenshot.java | 14 ++++++++++++-
2 files changed, 31 insertions(+), 3 deletions(-)
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..2270ce1 100644
--- a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java
+++ b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java
@@ -25,6 +25,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 +43,7 @@ public final class SectionMesher {
private final int blend;
private final float[] scratch = new float[12];
private final float[] sideUv = new float[8];
+ private int debugTint = 0xFFFFFF;
/**
* Creates a mesher.
@@ -70,6 +76,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;
@@ -117,7 +126,7 @@ private void meshBlock(BlockState state, boolean solid, int x, int y, int z, Qua
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 tintRgb = multiply(quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF, this.debugTint);
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);
}
@@ -128,7 +137,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 +179,13 @@ private float light(int x, int y, int z, Direction face, boolean solid) {
return Math.max(level / (4.0f - 3.0f * level), MIN_LIGHT);
}
+ 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/msmp/endpoints/screenshot/Screenshot.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
index 8a12589..8ff27b7 100644
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
@@ -1,5 +1,7 @@
package dev.loat.msmp_vision.msmp.endpoints.screenshot;
+import java.util.Locale;
+
import dev.loat.msmp.MSMPNamespace;
import dev.loat.msmp_vision.VisionService;
import dev.loat.msmp_vision.config.Config;
@@ -62,7 +64,17 @@ public static void register(MSMPNamespace namespace, VisionService service) {
try {
ScreenshotResponse response = service.screenshot(server, params);
RPCConnectionLogger.info(client.connectionId(),
- "vision:screenshot - rendered %dx%d in %dms on %d threads".formatted(response.width(), response.height(), response.renderTimeMs(), Config.getConfig().render.threads));
+ String.format(Locale.ROOT,
+ "vision:screenshot - rendered %dx%d in %dms on %d threads at (%f, %f, %f)",
+ response.width(),
+ response.height(),
+ response.renderTimeMs(),
+ Config.getConfig().render.threads,
+ 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());
From 30ffd2b31a92040abb8a62876f0e6f35fc3a2ec9 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Thu, 22 Oct 2026 17:39:30 +0200
Subject: [PATCH 12/18] fix: render distance
---
README.md | 4 +-
.../config/files/defaults/DefaultsConfig.java | 6 +-
.../config/files/limits/LimitsConfig.java | 6 +-
.../java/dev/loat/msmp_vision/raster/Fog.java | 71 ++++++++++++++-----
.../msmp_vision/raster/TileRasterizer.java | 2 +-
.../dev/loat/msmp_vision/render/Camera.java | 2 +-
.../msmp_vision/render/RenderSettings.java | 11 ++-
.../msmp_vision/render/SceneRenderer.java | 5 +-
.../msmp_vision/world/SnapshotCapture.java | 6 +-
9 files changed, 76 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
index 650e6b3..621aa2a 100644
--- a/README.md
+++ b/README.md
@@ -61,8 +61,8 @@ limits:
# @default: 1080
max-height: 1080
# Maximum render distance in chunks (1 chunk = 16 blocks).
- # @default: 8
- max-render-distance-chunks: 8
+ # @default: 32
+ max-render-distance-chunks: 32
# Maximum supersampling factor.
# @default: 3
max-ssaa: 3
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
index d593b4d..3d432b7 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java
@@ -14,9 +14,9 @@ public class DefaultsConfig {
@Annotation.Comment("Vertical field of view in degrees.")
public double fov = 70.0;
- @Annotation.Comment("Render distance in chunks.")
- @Annotation.Key("render-distance")
- public int renderDistance = 4;
+ @Annotation.Comment("Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings.")
+ @Annotation.Key("render-distance-chunks")
+ public int renderDistanceChunks = 4;
@Annotation.Comment("Supersampling factor (1 = off).")
public int ssaa = 1;
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
index 8c9f29e..f9d83c2 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
@@ -13,9 +13,9 @@ public class LimitsConfig {
@Annotation.Key("max-height")
public int maxHeight = 1080;
- @Annotation.Comment("Maximum render distance in chunks.")
- @Annotation.Key("max-render-distance")
- public int maxRenderDistance = 32;
+ @Annotation.Comment("Maximum render distance in chunks (1 chunk = 16 blocks).")
+ @Annotation.Key("max-render-distance-chunks")
+ public int maxRenderDistanceChunks = 32;
@Annotation.Comment("Maximum supersampling factor.")
@Annotation.Key("max-ssaa")
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..c6f6a6d 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,72 @@
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 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;
+ }
/**
* 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) {
+ 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/TileRasterizer.java b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java
index 6a20ae8..56b45d6 100644
--- a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java
+++ b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java
@@ -186,7 +186,7 @@ private void drawTriangle(float[] d, int o, TextureData texture, boolean translu
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 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 aad7abd..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);
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 29a1854..4943a47 100644
--- a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
+++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java
@@ -19,7 +19,7 @@
* @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 chunks.
+ * @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.
*/
@@ -62,7 +62,7 @@ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFil
int width = request.width().orElse(defaultsConfig.width);
int height = request.height().orElse(defaultsConfig.height);
double fov = request.fov().orElse(defaultsConfig.fov);
- int distance = request.renderDistance().orElse(defaultsConfig.renderDistance);
+ int distance = request.renderDistance().orElse(defaultsConfig.renderDistanceChunks);
int ssaa = request.ssaa().orElse(defaultsConfig.ssaa);
int blend = request.biomeBlend().orElse(defaultsConfig.biomeBlend);
@@ -74,7 +74,7 @@ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFil
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, "'render_distance' exceeds the limit of " + limitsConfig.maxRenderDistance);
+ require(distance <= limitsConfig.maxRenderDistanceChunks, "'render_distance' exceeds the limit of " + limitsConfig.maxRenderDistanceChunks + " chunks");
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);
require((long) width * ssaa * height * ssaa <= limitsConfig.maxRenderedPixels, "width*height*ssaa^2 exceeds " + limitsConfig.maxRenderedPixels + " pixels");
@@ -92,6 +92,11 @@ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFil
);
}
+ /** @return The render distance in blocks. */
+ public int renderDistanceBlocks() {
+ return this.renderDistance * 16;
+ }
+
/** @return Framebuffer width including supersampling. */
public int renderWidth() {
return this.width * this.ssaa;
diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
index 1e31928..f2899be 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
@@ -88,8 +88,9 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s
Framebuffer framebuffer = new Framebuffer(width, height);
SkyPainter.paint(framebuffer, camera, world.environment());
- float fogEnd = settings.renderDistance();
- Fog fog = Fog.of((float) (fogEnd * config.render.fogStartFactor), fogEnd, world.environment().fogColor());
+ float fogEnd = settings.renderDistanceBlocks();
+ Fog fog = Fog.of((float) (fogEnd * config.render.fogStartFactor), fogEnd, world.environment().fogColor(),
+ camera.tanHalfFov(), camera.aspect(), width, height);
new TileRasterizer(this.executor, config.render.tileSize, timeout)
.rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog);
return framebuffer.toImage(settings.ssaa());
diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
index b3425f3..ebea476 100644
--- a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
+++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java
@@ -61,7 +61,7 @@ public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSett
);
WorldSnapshot snapshot = new WorldSnapshot(environment);
- double radius = settings.renderDistance() + 16;
+ double radius = settings.renderDistanceBlocks() + 16;
ServerChunkCache cache = level.getChunkSource();
LevelLightEngine light = level.getLightEngine();
int minSectionY = level.getMinSectionY();
@@ -93,8 +93,8 @@ public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSett
}
}
- Logger.info("Captured %d section(s) from %d chunk column(s) in view (%d already loaded, %d newly loaded, %d skipped) in %d ms."
- .formatted(snapshot.sections().size(), columns.size(), resident, loaded, skipped, (System.nanoTime() - start) / 1_000_000L));
+ Logger.info("Captured %d section(s) from %d chunk column(s) in view at render distance %d chunks (%d already loaded, %d newly loaded, %d skipped) in %d ms."
+ .formatted(snapshot.sections().size(), columns.size(), settings.renderDistance(), resident, loaded, skipped, (System.nanoTime() - start) / 1_000_000L));
if (skipped > 0) {
Logger.warning("Chunk loading limit reached: %d chunk(s) were not loaded and are missing in the screenshot. Raise 'chunks.max-chunk-loads' or 'chunks.load-timeout-seconds', or lower the render distance."
.formatted(skipped));
From ea60f6a2ce79b791b8aaaf88bcbf71b82f64f8c8 Mon Sep 17 00:00:00 2001
From: Mqxx <62719703+Mqxx@users.noreply.github.com>
Date: Mon, 26 Oct 2026 07:44:29 +0100
Subject: [PATCH 13/18] fix: optimize
---
.../java/dev/loat/msmp_vision/MSMPVision.java | 3 +
.../dev/loat/msmp_vision/VisionService.java | 215 ++++++++++++++--
.../config/files/chunks/ChunksConfig.java | 7 +
.../config/files/limits/LimitsConfig.java | 4 +
.../msmp_vision/msmp/endpoints/Endpoints.java | 11 +
.../screenshot/ScreenshotNotifier.java | 64 +++++
.../NotificationScreenshotCompleted.java | 29 +++
...otificationScreenshotCompletedPayload.java | 50 ++++
.../failed/NotificationScreenshotFailed.java | 29 +++
.../NotificationScreenshotFailedPayload.java | 31 +++
.../screenshot/queue/ScreenshotQueue.java | 59 +++++
.../queue/ScreenshotQueueResponse.java | 32 +++
.../msmp/exceptions/QueueFullException.java | 14 ++
.../dev/loat/msmp_vision/render/Rendered.java | 12 +
.../msmp_vision/render/SceneRenderer.java | 10 +-
.../msmp_vision/world/SnapshotSession.java | 234 ++++++++++++++++++
16 files changed, 779 insertions(+), 25 deletions(-)
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
create mode 100644 src/main/java/dev/loat/msmp_vision/render/Rendered.java
create mode 100644 src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java
diff --git a/src/main/java/dev/loat/msmp_vision/MSMPVision.java b/src/main/java/dev/loat/msmp_vision/MSMPVision.java
index 6eb9f66..48cdf0d 100644
--- a/src/main/java/dev/loat/msmp_vision/MSMPVision.java
+++ b/src/main/java/dev/loat/msmp_vision/MSMPVision.java
@@ -7,6 +7,7 @@
import dev.loat.msmp_vision.msmp.endpoints.Endpoints;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
+import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.server.jsonrpc.ManagementServer;
/**
@@ -47,6 +48,8 @@ public void onInitialize() {
this.service.start();
});
+ ServerTickEvents.END_SERVER_TICK.register(this.service::tick);
+
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
this.service.stop();
NAMESPACE.detach();
diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java
index 2951397..510c69f 100644
--- a/src/main/java/dev/loat/msmp_vision/VisionService.java
+++ b/src/main/java/dev/loat/msmp_vision/VisionService.java
@@ -7,15 +7,17 @@
import dev.loat.msmp_vision.encode.ImageEncoder;
import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.mesh.TintResolver;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotNotifier;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotResponse;
+import dev.loat.msmp_vision.msmp.exceptions.QueueFullException;
import dev.loat.msmp_vision.msmp.exceptions.UnknownDimensionException;
import dev.loat.msmp_vision.render.Camera;
import dev.loat.msmp_vision.render.RenderExecutor;
import dev.loat.msmp_vision.render.RenderSettings;
+import dev.loat.msmp_vision.render.Rendered;
import dev.loat.msmp_vision.render.SceneRenderer;
-import dev.loat.msmp_vision.world.SnapshotCapture;
-import dev.loat.msmp_vision.world.WorldSnapshot;
+import dev.loat.msmp_vision.world.SnapshotSession;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
@@ -24,26 +26,66 @@
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
-import java.awt.image.BufferedImage;
import java.nio.file.Path;
import java.util.Optional;
+import java.util.Queue;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Handles screenshot requests: validates, captures the world on the server thread and renders on worker threads.
+ *
+ * Two ways to take a screenshot:
+ *
+ * - {@link #screenshot}: synchronous. Blocks the server thread until the image is ready.
+ * - {@link #queue} + {@link #tick}: asynchronous. The world is captured in small slices over several ticks,
+ * rendering and PNG encoding happen off the server thread and the result is sent as a notification.
+ *
*/
public final class VisionService {
private record Runtime(
RenderExecutor executor,
+ ExecutorService coordinator,
SceneRenderer renderer,
CompletableFuture ready
) {}
+ private record Prepared(RenderSettings settings, ServerLevel level, Camera camera) {}
+
+ private static final class Job {
+ final String id = UUID.randomUUID().toString();
+ final Integer connectionId;
+ final Prepared prepared;
+ final long createdNanos = System.nanoTime();
+ SnapshotSession session;
+
+ Job(Integer connectionId, Prepared prepared) {
+ this.connectionId = connectionId;
+ this.prepared = prepared;
+ }
+ }
+
private volatile Runtime runtime;
+ private volatile ScreenshotNotifier notifier;
+ private final Queue queue = new ConcurrentLinkedQueue<>();
+ private final AtomicInteger pending = new AtomicInteger();
+
+ /**
+ * Sets the notifier used to deliver queued screenshots. Call once during initialization.
+ *
+ * @param notifier The notifier.
+ */
+ public void bindNotifier(ScreenshotNotifier notifier) {
+ this.notifier = notifier;
+ }
/**
* Starts asset preparation and the render pool. Call on server start.
@@ -56,6 +98,11 @@ public void start() {
TextureStore textures = new TextureStore(assets);
RenderExecutor executor = new RenderExecutor(config.render.threads);
SceneRenderer renderer = new SceneRenderer(assets, textures, executor);
+ ExecutorService coordinator = Executors.newSingleThreadExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "msmp-vision-coordinator");
+ thread.setDaemon(true);
+ return thread;
+ });
CompletableFuture ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets));
ready.whenComplete((ignored, error) -> {
@@ -65,20 +112,23 @@ public void start() {
Logger.info("Client assets ready (" + executor.threads() + " render threads).");
}
});
- this.runtime = new Runtime(executor, renderer, ready);
+ this.runtime = new Runtime(executor, coordinator, renderer, ready);
}
- /** Stops the render pool. Call on server stop. */
+ /** Stops the render pool and drops all queued screenshots. Call on server stop. */
public void stop() {
Runtime current = this.runtime;
this.runtime = null;
+ this.queue.clear();
+ this.pending.set(0);
if (current != null) {
+ current.coordinator().shutdownNow();
current.executor().shutdown();
}
}
/**
- * Renders a screenshot.
+ * Renders a screenshot and blocks until it is ready.
*
* @param server The server.
* @param request The request.
@@ -88,6 +138,122 @@ public void stop() {
* @throws IllegalStateException if the renderer is not ready or rendering failed.
*/
public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) {
+ Runtime current = this.requireReady();
+ MSMPVisionConfigFile config = Config.getConfig();
+ Prepared prepared = this.prepare(server, request, config);
+
+ long start = System.nanoTime();
+ SnapshotSession session = this.captureBlocking(server, prepared, config);
+ Rendered rendered = current.renderer().render(session.snapshot(), prepared.camera(), prepared.settings(), config);
+ long encodeStart = System.nanoTime();
+ String encoded = ImageEncoder.toBase64Png(rendered.image());
+ long end = System.nanoTime();
+
+ logTimings(session.workNanos(), rendered, end - encodeStart, end - start);
+ return new ScreenshotResponse(encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - start) / 1_000_000L));
+ }
+
+ /**
+ * Queues a screenshot. The call returns immediately; the result is delivered with a notification to the
+ * given connection, see {@link #tick(MinecraftServer)}.
+ *
+ * @param server The server.
+ * @param connectionId The connection to notify.
+ * @param request The request.
+ * @return The id of the queued screenshot.
+ * @throws QueueFullException if too many screenshots are pending.
+ * @throws dev.loat.msmp_vision.msmp.exceptions.InvalidParamsException for invalid parameters.
+ * @throws UnknownDimensionException if the requested dimension does not exist.
+ * @throws IllegalStateException if the renderer is not ready.
+ */
+ public String queue(MinecraftServer server, Integer connectionId, ScreenshotRequest request) {
+ this.requireReady();
+ if (this.notifier == null) {
+ throw new IllegalStateException("Notifications are not available.");
+ }
+ MSMPVisionConfigFile config = Config.getConfig();
+ int limit = config.limits.maxPendingScreenshots;
+ if (this.pending.get() >= limit) {
+ throw new QueueFullException(limit);
+ }
+
+ Job job = new Job(connectionId, this.prepare(server, request, config));
+ this.pending.incrementAndGet();
+ this.queue.add(job);
+ return job.id;
+ }
+
+ /**
+ * Advances the oldest queued screenshot by one slice. Call once per server tick (on the server thread).
+ *
+ * @param server The server.
+ */
+ public void tick(MinecraftServer server) {
+ Job job = this.queue.peek();
+ if (job == null) {
+ return;
+ }
+ try {
+ Runtime current = this.runtime;
+ if (current == null) {
+ throw new IllegalStateException("The renderer is not running.");
+ }
+ MSMPVisionConfigFile config = Config.getConfig();
+ long timeoutNanos = TimeUnit.SECONDS.toNanos(config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds);
+ if (System.nanoTime() - job.createdNanos > timeoutNanos) {
+ throw new IllegalStateException("Timed out while capturing the world.");
+ }
+ if (job.session == null) {
+ job.session = new SnapshotSession(job.prepared.level(), job.prepared.camera(), job.prepared.settings(), config.chunks);
+ }
+ long budget = TimeUnit.MILLISECONDS.toNanos(Math.max(1, config.chunks.tickBudgetMs));
+ if (!job.session.step(budget)) {
+ return;
+ }
+ this.queue.remove(job);
+ this.render(server, current, job);
+ } catch (RuntimeException e) {
+ this.queue.remove(job);
+ this.fail(job, e);
+ }
+ }
+
+ /** Hands a captured screenshot over to the coordinator thread, which renders and encodes it. */
+ private void render(MinecraftServer server, Runtime current, Job job) {
+ SnapshotSession session = job.session;
+ current.coordinator().execute(() -> {
+ try {
+ MSMPVisionConfigFile config = Config.getConfig();
+ Rendered rendered = current.renderer().render(session.snapshot(), job.prepared.camera(), job.prepared.settings(), config);
+ long encodeStart = System.nanoTime();
+ String encoded = ImageEncoder.toBase64Png(rendered.image());
+ long end = System.nanoTime();
+
+ logTimings(session.workNanos(), rendered, end - encodeStart, end - job.createdNanos);
+ ScreenshotResponse response = new ScreenshotResponse(
+ encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - job.createdNanos) / 1_000_000L)
+ );
+ server.execute(() -> {
+ this.pending.decrementAndGet();
+ this.notifier.completed(job.connectionId, job.id, response);
+ });
+ } catch (Throwable error) {
+ server.execute(() -> this.fail(job, error));
+ }
+ });
+ }
+
+ private void fail(Job job, Throwable error) {
+ this.pending.decrementAndGet();
+ String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
+ Logger.warning("Queued screenshot %s failed: %s".formatted(job.id, message));
+ ScreenshotNotifier current = this.notifier;
+ if (current != null) {
+ current.failed(job.connectionId, job.id, message);
+ }
+ }
+
+ private Runtime requireReady() {
Runtime current = this.runtime;
if (current == null) {
throw new IllegalStateException("The renderer is not running.");
@@ -98,33 +264,26 @@ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest r
if (current.ready().isCompletedExceptionally()) {
throw new IllegalStateException("Client assets could not be prepared, see the server log.");
}
+ return current;
+ }
- MSMPVisionConfigFile config = Config.getConfig();
+ private Prepared prepare(MinecraftServer server, ScreenshotRequest request, MSMPVisionConfigFile config) {
RenderSettings settings = RenderSettings.from(request, config);
ServerLevel level = this.resolveLevel(server, request.dimension());
-
- long start = System.nanoTime();
- Camera camera = new Camera(settings);
- WorldSnapshot snapshot = this.capture(server, level, camera, settings, config);
- BufferedImage image = current.renderer().render(snapshot, camera, settings, config);
- String encoded = ImageEncoder.toBase64Png(image);
- int elapsed = (int) ((System.nanoTime() - start) / 1_000_000L);
-
- return new ScreenshotResponse(encoded, "png", image.getWidth(), image.getHeight(), elapsed);
+ return new Prepared(settings, level, new Camera(settings));
}
/**
- * Captures the world on the server thread. MSMP handlers already run there, in which case the capture
- * (including loading missing chunks) is done directly; otherwise it is scheduled on the server thread.
+ * Captures the whole world at once on the server thread. MSMP handlers already run there, in which case the
+ * capture (including loading missing chunks) is done directly; otherwise it is scheduled on the server thread.
*/
- private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
+ private SnapshotSession captureBlocking(MinecraftServer server, Prepared prepared, MSMPVisionConfigFile config) {
if (server.isSameThread()) {
- return SnapshotCapture.capture(level, camera, settings, config.chunks);
+ return captureAll(prepared, config);
}
long timeoutSeconds = config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds;
try {
- return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings, config.chunks), server)
- .get(timeoutSeconds, TimeUnit.SECONDS);
+ return CompletableFuture.supplyAsync(() -> captureAll(prepared, config), server).get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("The server thread did not respond in time.");
} catch (ExecutionException e) {
@@ -135,6 +294,18 @@ private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera
}
}
+ private static SnapshotSession captureAll(Prepared prepared, MSMPVisionConfigFile config) {
+ SnapshotSession session = new SnapshotSession(prepared.level(), prepared.camera(), prepared.settings(), config.chunks);
+ session.step(Long.MAX_VALUE);
+ return session;
+ }
+
+ private static void logTimings(long captureNanos, Rendered rendered, long encodeNanos, long totalNanos) {
+ Logger.info("Screenshot timings: capture %d ms (server thread), mesh %d ms, raster %d ms, encode %d ms, total %d ms."
+ .formatted(captureNanos / 1_000_000L, rendered.meshNanos() / 1_000_000L, rendered.rasterNanos() / 1_000_000L,
+ encodeNanos / 1_000_000L, totalNanos / 1_000_000L));
+ }
+
private ServerLevel resolveLevel(MinecraftServer server, Optional dimension) {
if (dimension.isEmpty()) {
return server.overworld();
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
index 5265836..42f3e78 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
@@ -26,6 +26,13 @@ Maximum number of chunks that may be loaded (or generated) for a single request.
@Annotation.Key("load-timeout-seconds")
public int loadTimeoutSeconds = 10;
+ @Annotation.Comment("""
+ Maximum time in milliseconds per server tick that is spent on loading chunks for queued screenshots
+ (vision:screenshot/queue). Lower values keep the server smoother but make queued screenshots take longer.
+ """)
+ @Annotation.Key("tick-budget-ms")
+ public int tickBudgetMs = 15;
+
public static enum Mode {
NONE,
LOAD
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
index f9d83c2..40c278c 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
@@ -32,4 +32,8 @@ public class LimitsConfig {
@Annotation.Comment("Maximum field of view in degrees.")
@Annotation.Key("max-fov")
public double maxFov = 110.0;
+
+ @Annotation.Comment("Maximum number of queued screenshots (vision:screenshot/queue) that may be pending at the same time.")
+ @Annotation.Key("max-pending-screenshots")
+ public int maxPendingScreenshots = 4;
}
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 f0b732a..e0e4af8 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
@@ -4,6 +4,10 @@
import dev.loat.msmp.MSMPServer;
import dev.loat.msmp_vision.VisionService;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.Screenshot;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotNotifier;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed.NotificationScreenshotCompleted;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed.NotificationScreenshotFailed;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.queue.ScreenshotQueue;
import java.util.function.Supplier;
@@ -26,5 +30,12 @@ private Endpoints() {}
public static void register(MSMPNamespace namespace, Supplier msmpServer, VisionService service) {
Screenshot.register(namespace, service);
+
+ service.bindNotifier(new ScreenshotNotifier(
+ NotificationScreenshotCompleted.register(namespace),
+ NotificationScreenshotFailed.register(namespace),
+ msmpServer
+ ));
+ ScreenshotQueue.register(namespace, service);
}
}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
new file mode 100644
index 0000000..889acf4
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
@@ -0,0 +1,64 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot;
+
+import dev.loat.msmp.MSMPNotification;
+import dev.loat.msmp.MSMPServer;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed.NotificationScreenshotCompletedPayload;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed.NotificationScreenshotFailedPayload;
+
+import java.util.function.Supplier;
+
+
+/**
+ * Sends the results of queued screenshots to the connection that requested them.
+ *
+ * Must be used on the server thread.
+ */
+public final class ScreenshotNotifier {
+
+ private final MSMPNotification completed;
+ private final MSMPNotification failed;
+ private final Supplier msmpServer;
+
+ /**
+ * @param completed The completion notification
+ * @param failed The failure notification
+ * @param msmpServer A supplier for the running {@link MSMPServer}, {@code null} when no server is running
+ */
+ public ScreenshotNotifier(
+ MSMPNotification completed,
+ MSMPNotification failed,
+ Supplier msmpServer
+ ) {
+ this.completed = completed;
+ this.failed = failed;
+ this.msmpServer = msmpServer;
+ }
+
+ /**
+ * Sends a finished screenshot.
+ *
+ * @param connectionId The connection that queued the screenshot
+ * @param id The screenshot id
+ * @param response The rendered image
+ */
+ public void completed(Integer connectionId, String id, ScreenshotResponse response) {
+ MSMPServer server = this.msmpServer.get();
+ if (server == null) return;
+ server.sendTo(connectionId, this.completed, new NotificationScreenshotCompletedPayload(
+ id, response.image(), response.format(), response.width(), response.height(), response.renderTimeMs()
+ ));
+ }
+
+ /**
+ * Sends a failure.
+ *
+ * @param connectionId The connection that queued the screenshot
+ * @param id The screenshot id
+ * @param message What went wrong
+ */
+ public void failed(Integer connectionId, String id, String message) {
+ MSMPServer server = this.msmpServer.get();
+ if (server == null) return;
+ server.sendTo(connectionId, this.failed, new NotificationScreenshotFailedPayload(id, message));
+ }
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
new file mode 100644
index 0000000..a2a30ac
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
@@ -0,0 +1,29 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed;
+
+import dev.loat.msmp.MSMPNamespace;
+import dev.loat.msmp.MSMPNotification;
+
+
+/**
+ * Registers the {@code vision:notification/screenshot/completed} MSMP notification.
+ *
+ * Sent to the connection that queued a screenshot with {@code vision:screenshot/queue}
+ * as soon as the image is ready. Not broadcast to other connections.
+ */
+public class NotificationScreenshotCompleted {
+
+ private NotificationScreenshotCompleted() {}
+
+ /**
+ * Registers the notification.
+ *
+ * @param namespace The namespace to register this notification under
+ * @return The registered notification, used to send it
+ */
+ public static MSMPNotification register(MSMPNamespace namespace) {
+ return namespace.notification("screenshot/completed")
+ .description("Sent to the requesting connection when a queued screenshot is ready")
+ .responseSchema(NotificationScreenshotCompletedPayload.SCHEMA)
+ .register();
+ }
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
new file mode 100644
index 0000000..49a298b
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
@@ -0,0 +1,50 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.server.jsonrpc.api.Schema;
+
+
+/**
+ * Payload for the {@code vision:notification/screenshot/completed} notification.
+ *
+ * Example JSON representation:
+ *
+ * {
+ * "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10",
+ * "image": "iVBORw0KGgoAAAANSUhEUg...",
+ * "format": "png",
+ * "width": 512,
+ * "height": 288,
+ * "render_time_ms": 1240
+ * }
+ *
+ *
+ * @param id Identifier returned by {@code vision:screenshot/queue}
+ * @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 Time from queuing until the image was ready, in milliseconds
+ */
+public record NotificationScreenshotCompletedPayload(String id, String image, String format, int width, int height, int renderTimeMs) {
+
+ /** Codec for serializing and deserializing {@link NotificationScreenshotCompletedPayload} instances. */
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("id").forGetter(NotificationScreenshotCompletedPayload::id),
+ Codec.STRING.fieldOf("image").forGetter(NotificationScreenshotCompletedPayload::image),
+ Codec.STRING.fieldOf("format").forGetter(NotificationScreenshotCompletedPayload::format),
+ Codec.INT.fieldOf("width").forGetter(NotificationScreenshotCompletedPayload::width),
+ Codec.INT.fieldOf("height").forGetter(NotificationScreenshotCompletedPayload::height),
+ Codec.INT.fieldOf("render_time_ms").forGetter(NotificationScreenshotCompletedPayload::renderTimeMs)
+ ).apply(i, NotificationScreenshotCompletedPayload::new));
+
+ /** MSMP schema for {@link NotificationScreenshotCompletedPayload}, used for protocol discovery. */
+ public static final Schema SCHEMA = Schema.record(CODEC)
+ .withField("id", Schema.STRING_SCHEMA)
+ .withField("image", Schema.STRING_SCHEMA)
+ .withField("format", Schema.STRING_SCHEMA)
+ .withField("width", Schema.INT_SCHEMA)
+ .withField("height", Schema.INT_SCHEMA)
+ .withField("render_time_ms", Schema.INT_SCHEMA);
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
new file mode 100644
index 0000000..9c28099
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
@@ -0,0 +1,29 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed;
+
+import dev.loat.msmp.MSMPNamespace;
+import dev.loat.msmp.MSMPNotification;
+
+
+/**
+ * Registers the {@code vision:notification/screenshot/failed} MSMP notification.
+ *
+ * Sent to the connection that queued a screenshot with {@code vision:screenshot/queue}
+ * when the screenshot could not be completed. Not broadcast to other connections.
+ */
+public class NotificationScreenshotFailed {
+
+ private NotificationScreenshotFailed() {}
+
+ /**
+ * Registers the notification.
+ *
+ * @param namespace The namespace to register this notification under
+ * @return The registered notification, used to send it
+ */
+ public static MSMPNotification register(MSMPNamespace namespace) {
+ return namespace.notification("screenshot/failed")
+ .description("Sent to the requesting connection when a queued screenshot failed")
+ .responseSchema(NotificationScreenshotFailedPayload.SCHEMA)
+ .register();
+ }
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
new file mode 100644
index 0000000..0f25c43
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
@@ -0,0 +1,31 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.server.jsonrpc.api.Schema;
+
+
+/**
+ * Payload for the {@code vision:notification/screenshot/failed} notification.
+ *
+ * Example JSON representation:
+ *
+ * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10", "message": "Rendering timed out after 30s" }
+ *
+ *
+ * @param id Identifier returned by {@code vision:screenshot/queue}
+ * @param message A human-readable description of what went wrong
+ */
+public record NotificationScreenshotFailedPayload(String id, String message) {
+
+ /** Codec for serializing and deserializing {@link NotificationScreenshotFailedPayload} instances. */
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("id").forGetter(NotificationScreenshotFailedPayload::id),
+ Codec.STRING.fieldOf("message").forGetter(NotificationScreenshotFailedPayload::message)
+ ).apply(i, NotificationScreenshotFailedPayload::new));
+
+ /** MSMP schema for {@link NotificationScreenshotFailedPayload}, used for protocol discovery. */
+ public static final Schema SCHEMA = Schema.record(CODEC)
+ .withField("id", Schema.STRING_SCHEMA)
+ .withField("message", Schema.STRING_SCHEMA);
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
new file mode 100644
index 0000000..bca0bbb
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
@@ -0,0 +1,59 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.queue;
+
+import dev.loat.msmp.MSMPNamespace;
+import dev.loat.msmp_vision.VisionService;
+import dev.loat.msmp_vision.logging.RPCConnectionLogger;
+import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest;
+
+
+/**
+ * Registers the {@code vision:screenshot/queue} MSMP method.
+ *
+ * Same parameters as {@code vision:screenshot}, but the call returns immediately with an {@code id}.
+ * The world is captured in small slices over several server ticks, rendered on worker threads and the result is
+ * delivered with the {@code vision:notification/screenshot/completed} (or {@code .../failed}) notification to the
+ * connection that queued it. In contrast to {@code vision:screenshot}, the server keeps ticking meanwhile.
+ *
+ * Example request:
+ *
+ * {
+ * "jsonrpc": "2.0",
+ * "id": 1,
+ * "method": "vision:screenshot/queue",
+ * "params": [{ "position": [128.5, 80.0, -32.3], "rotation": [90.0, 0.0] }]
+ * }
+ *
+ *
+ * Example response:
+ *
+ * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10" }
+ *
+ */
+public class ScreenshotQueue {
+
+ private ScreenshotQueue() {}
+
+ /**
+ * Registers the {@code vision:screenshot/queue} method on the given {@link MSMPNamespace}.
+ *
+ * @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/queue")
+ .description("Queues a server-side screenshot and returns its id immediately; the image is delivered with a notification")
+ .requestSchema(ScreenshotRequest.SCHEMA)
+ .responseSchema(ScreenshotQueueResponse.SCHEMA)
+ .register((server, client, params) -> {
+ try {
+ String id = service.queue(server, client.connectionId(), params);
+ RPCConnectionLogger.info(client.connectionId(), "vision:screenshot/queue - queued " + id);
+ return new ScreenshotQueueResponse(id);
+ } catch (RuntimeException e) {
+ RPCConnectionLogger.warning(client.connectionId(), "vision:screenshot/queue - " + e.getMessage());
+ throw e;
+ }
+ });
+ }
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
new file mode 100644
index 0000000..5242313
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
@@ -0,0 +1,32 @@
+package dev.loat.msmp_vision.msmp.endpoints.screenshot.queue;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.server.jsonrpc.api.Schema;
+
+
+/**
+ * Response payload for the {@code vision:screenshot/queue} method.
+ *
+ * Example JSON representation:
+ *
+ * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10" }
+ *
+ *
+ * @param id Identifier of the queued screenshot, repeated in the completion notification
+ */
+public record ScreenshotQueueResponse(String id) {
+
+ /**
+ * Codec for serializing and deserializing {@link ScreenshotQueueResponse} instances.
+ */
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("id").forGetter(ScreenshotQueueResponse::id)
+ ).apply(i, ScreenshotQueueResponse::new));
+
+ /**
+ * MSMP schema for {@link ScreenshotQueueResponse}, used for protocol discovery.
+ */
+ public static final Schema SCHEMA = Schema.record(CODEC)
+ .withField("id", Schema.STRING_SCHEMA);
+}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
new file mode 100644
index 0000000..f7188b8
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
@@ -0,0 +1,14 @@
+package dev.loat.msmp_vision.msmp.exceptions;
+
+/**
+ * Thrown when too many queued screenshots are pending.
+ */
+public class QueueFullException extends MSMPException {
+
+ /**
+ * @param limit The configured maximum number of pending screenshots
+ */
+ public QueueFullException(int limit) {
+ super("Too many pending screenshots (limit %d), try again later".formatted(limit));
+ }
+}
diff --git a/src/main/java/dev/loat/msmp_vision/render/Rendered.java b/src/main/java/dev/loat/msmp_vision/render/Rendered.java
new file mode 100644
index 0000000..537f765
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/render/Rendered.java
@@ -0,0 +1,12 @@
+package dev.loat.msmp_vision.render;
+
+import java.awt.image.BufferedImage;
+
+/**
+ * Result of a render together with the time each phase took.
+ *
+ * @param image The final image.
+ * @param meshNanos Time spent meshing and projecting all sections.
+ * @param rasterNanos Time spent painting the sky, rasterizing and downsampling.
+ */
+public record Rendered(BufferedImage image, long meshNanos, long rasterNanos) {}
diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
index f2899be..3d87d78 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
@@ -56,9 +56,10 @@ public SceneRenderer(AssetManager assets, TextureStore textures, RenderExecutor
* @param camera The camera.
* @param settings The settings.
* @param config The current configuration.
- * @return The final image.
+ * @return The final image together with phase timings.
*/
- public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
+ public Rendered render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
+ long startNanos = System.nanoTime();
long timeout = config.requestTimeoutSeconds;
int width = settings.renderWidth();
int height = settings.renderHeight();
@@ -76,6 +77,7 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s
}, this.executor.executor()));
}
this.executor.awaitAll(jobs, timeout);
+ long meshedNanos = System.nanoTime();
List opaque = new ArrayList<>(jobs.size());
List translucent = new ArrayList<>(jobs.size());
@@ -93,7 +95,9 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s
camera.tanHalfFov(), camera.aspect(), width, height);
new TileRasterizer(this.executor, config.render.tileSize, timeout)
.rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog);
- return framebuffer.toImage(settings.ssaa());
+ BufferedImage image = framebuffer.toImage(settings.ssaa());
+ long doneNanos = System.nanoTime();
+ return new Rendered(image, meshedNanos - startNanos, doneNanos - meshedNanos);
}
private TextureData[] texturesById() {
diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java
new file mode 100644
index 0000000..9c28541
--- /dev/null
+++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java
@@ -0,0 +1,234 @@
+package dev.loat.msmp_vision.world;
+
+import dev.loat.msmp_vision.config.files.chunks.ChunksConfig;
+import dev.loat.msmp_vision.logging.Logger;
+import dev.loat.msmp_vision.render.Camera;
+import dev.loat.msmp_vision.render.RenderSettings;
+import net.minecraft.core.SectionPos;
+import net.minecraft.server.level.ServerChunkCache;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.util.Mth;
+import net.minecraft.world.attribute.EnvironmentAttributes;
+import net.minecraft.world.level.LightLayer;
+import net.minecraft.world.level.chunk.ChunkAccess;
+import net.minecraft.world.level.chunk.DataLayer;
+import net.minecraft.world.level.chunk.LevelChunk;
+import net.minecraft.world.level.chunk.LevelChunkSection;
+import net.minecraft.world.level.chunk.status.ChunkStatus;
+import net.minecraft.world.level.lighting.LevelLightEngine;
+import net.minecraft.world.phys.Vec3;
+import org.joml.Vector3fc;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Copies the chunk sections around the camera into a {@link WorldSnapshot}, in slices of limited duration.
+ * Every method must be called on the server thread.
+ *
+ * Chunks 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 columns;
+ private final double radius;
+ private final int minSectionY;
+ private final long loadLimitNanos;
+
+ private int next;
+ private int resident;
+ private int loaded;
+ private int skipped;
+ private long workNanos;
+ private boolean finished;
+
+ /**
+ * Prepares a capture: reads the environment and determines which chunk columns are in view.
+ *
+ * @param level The level.
+ * @param camera The camera used for frustum culling.
+ * @param settings The render settings.
+ * @param chunks The chunk loading configuration.
+ */
+ public SnapshotSession(ServerLevel level, Camera camera, RenderSettings settings, ChunksConfig chunks) {
+ this.level = level;
+ this.camera = camera;
+ this.settings = settings;
+ this.chunks = chunks;
+
+ Vec3 position = new Vec3(settings.position().get(0), settings.position().get(1), settings.position().get(2));
+ this.environment = new EnvironmentSnapshot(
+ level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD,
+ level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position),
+ toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position), FALLBACK_FOG),
+ toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position), FALLBACK_SKY),
+ level.dimensionType().hasSkyLight()
+ );
+ this.snapshot = new WorldSnapshot(this.environment);
+ this.radius = settings.renderDistanceBlocks() + 16;
+ this.minSectionY = level.getMinSectionY();
+ this.loadLimitNanos = TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds));
+ this.columns = visibleColumns(level, camera, settings, this.radius);
+ }
+
+ /**
+ * Processes chunk columns until the budget is used up (at least one column per call).
+ *
+ * @param budgetNanos Maximum time for this call, {@link Long#MAX_VALUE} for no limit.
+ * @return {@code true} once the capture is complete.
+ */
+ public boolean step(long budgetNanos) {
+ if (this.finished) {
+ return true;
+ }
+ ServerChunkCache cache = this.level.getChunkSource();
+ LevelLightEngine light = this.level.getLightEngine();
+ long stepStart = System.nanoTime();
+ int processed = 0;
+
+ while (this.next < this.columns.size()) {
+ if (processed > 0 && budgetNanos != Long.MAX_VALUE && System.nanoTime() - stepStart >= budgetNanos) {
+ break;
+ }
+ Column column = this.columns.get(this.next++);
+ processed++;
+
+ LevelChunk chunk = cache.getChunkNow(column.x(), column.z());
+ if (chunk != null) {
+ this.resident++;
+ } else if (this.chunks.mode == ChunksConfig.Mode.LOAD) {
+ long spent = this.workNanos + (System.nanoTime() - stepStart);
+ if (this.loaded >= this.chunks.maxChunkLoads || spent >= this.loadLimitNanos) {
+ this.skipped++;
+ continue;
+ }
+ ChunkAccess access = cache.getChunk(column.x(), column.z(), ChunkStatus.FULL, true);
+ this.loaded++;
+ if (access instanceof LevelChunk levelChunk) {
+ chunk = levelChunk;
+ }
+ }
+ if (chunk != null) {
+ this.copyColumn(chunk, column, light);
+ }
+ }
+ this.workNanos += System.nanoTime() - stepStart;
+
+ if (this.next < this.columns.size()) {
+ return false;
+ }
+ this.finished = true;
+ Logger.info("Captured %d section(s) from %d chunk column(s) in view at render distance %d chunks (%d already loaded, %d newly loaded, %d skipped) in %d ms."
+ .formatted(this.snapshot.sections().size(), this.columns.size(), this.settings.renderDistance(), this.resident, this.loaded, this.skipped, this.workNanos / 1_000_000L));
+ if (this.skipped > 0) {
+ Logger.warning("Chunk loading limit reached: %d chunk(s) were not loaded and are missing in the screenshot. Raise 'chunks.max-chunk-loads' or 'chunks.load-timeout-seconds', or lower the render distance."
+ .formatted(this.skipped));
+ }
+ return true;
+ }
+
+ /** @return The snapshot; only complete once {@link #step(long)} returned {@code true}. */
+ public WorldSnapshot snapshot() { return this.snapshot; }
+
+ /** @return Total time in nanoseconds spent inside {@link #step(long)}, i.e. blocked on the server thread. */
+ public long workNanos() { return this.workNanos; }
+
+ /** All chunk columns inside the render radius and the view frustum, nearest first. */
+ private static List visibleColumns(ServerLevel level, Camera camera, RenderSettings settings, double radius) {
+ double px = settings.position().get(0);
+ double pz = settings.position().get(2);
+ int minChunkX = Mth.floor(px - radius) >> 4;
+ int maxChunkX = Mth.floor(px + radius) >> 4;
+ int minChunkZ = Mth.floor(pz - radius) >> 4;
+ int maxChunkZ = Mth.floor(pz + radius) >> 4;
+ // Only sections within the render radius are ever used, so the column test only needs that height range.
+ // Testing the full world height would let the tilted frustum planes pick up many columns that can never be seen.
+ double py = settings.position().get(1);
+ int minY = Math.max(level.getMinY(), Mth.floor(py - radius));
+ int maxY = Math.min(level.getMinY() + level.getHeight(), Mth.ceil(py + radius));
+ double reach = radius + 12; // half diagonal of a chunk
+
+ List columns = new ArrayList<>();
+ for (int cz = minChunkZ; cz <= maxChunkZ; cz++) {
+ for (int cx = minChunkX; cx <= maxChunkX; cx++) {
+ double dx = (cx << 4) + 8 - px;
+ double dz = (cz << 4) + 8 - pz;
+ double distanceSquared = dx * dx + dz * dz;
+ if (distanceSquared > reach * reach || !camera.intersectsColumn(cx, cz, minY, maxY)) {
+ continue;
+ }
+ columns.add(new Column(cx, cz, distanceSquared));
+ }
+ }
+ columns.sort(Comparator.comparingDouble(Column::distanceSquared));
+ return columns;
+ }
+
+ private void copyColumn(LevelChunk chunk, Column column, LevelLightEngine light) {
+ LevelChunkSection[] sections = chunk.getSections();
+ for (int i = 0; i < sections.length; i++) {
+ int sy = this.minSectionY + i;
+ if (!this.isRelevant(column.x(), sy, column.z())) {
+ continue;
+ }
+ LevelChunkSection section = sections[i];
+ SectionPos pos = SectionPos.of(column.x(), sy, column.z());
+ this.snapshot.put(new SectionSnapshot(
+ column.x(), sy, column.z(),
+ section.hasOnlyAir() ? null : section.getStates().copy(),
+ section.getBiomes().copy(),
+ this.environment.hasSkyLight() ? copyLayer(light, LightLayer.SKY, pos) : null,
+ copyLayer(light, LightLayer.BLOCK, pos)
+ ));
+ }
+ }
+
+ private boolean isRelevant(int cx, int sy, int cz) {
+ double dx = (cx << 4) + 8 - this.settings.position().get(0);
+ double dy = (sy << 4) + 8 - this.settings.position().get(1);
+ double dz = (cz << 4) + 8 - this.settings.position().get(2);
+ return dx * dx + dy * dy + dz * dz <= this.radius * this.radius && this.camera.intersectsSection(cx, sy, cz);
+ }
+
+ /**
+ * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts packed integer colours (alpha is ignored)
+ * as well as RGB vectors (components 0..1) and clamps everything so nothing can overflow into a neighbouring channel.
+ */
+ private static int toRgb(Object raw, int fallback) {
+ if (raw instanceof Integer packed) {
+ return packed & 0xFFFFFF;
+ }
+ if (raw instanceof Vector3fc vector) {
+ return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z());
+ }
+ return fallback;
+ }
+
+ private static int channel(float unit) {
+ return Mth.clamp((int) (unit * 255.0f + 0.5f), 0, 255);
+ }
+
+ private static DataLayer copyLayer(LevelLightEngine engine, LightLayer layer, SectionPos pos) {
+ DataLayer data = engine.getLayerListener(layer).getDataLayerData(pos);
+ return data == null ? null : data.copy();
+ }
+}
From 13007698447346585c30c8f5aefd3b20dbad2902 Mon Sep 17 00:00:00 2001
From: Mqxx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 27 Oct 2026 13:00:24 +0100
Subject: [PATCH 14/18] fix: removed queue
---
README.md | 4 +
.../java/dev/loat/msmp_vision/MSMPVision.java | 3 -
.../dev/loat/msmp_vision/VisionService.java | 208 ++----------------
.../config/files/chunks/ChunksConfig.java | 7 -
.../config/files/limits/LimitsConfig.java | 4 -
.../config/files/render/RenderConfig.java | 7 +
.../loat/msmp_vision/encode/ImageEncoder.java | 100 ++++++++-
.../msmp_vision/msmp/endpoints/Endpoints.java | 11 -
.../msmp/endpoints/screenshot/Screenshot.java | 4 +-
.../java/dev/loat/msmp_vision/raster/Fog.java | 6 +
.../loat/msmp_vision/raster/Framebuffer.java | 4 +
.../dev/loat/msmp_vision/render/Rendered.java | 6 +-
.../msmp_vision/render/SceneRenderer.java | 7 +-
.../loat/msmp_vision/render/SkyPainter.java | 33 ++-
14 files changed, 174 insertions(+), 230 deletions(-)
diff --git a/README.md b/README.md
index 621aa2a..b3ace23 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,10 @@ rendering:
# Skip back-facing triangles. Disable only for debugging.
# @default: true
backface-culling: true
+ # Deflate level of the PNG encoder, 0 (no compression) to 9 (smallest file).
+ # 1 encodes about 2.5x faster than 6 for a file that is roughly 30% larger.
+ # @default: 1
+ png-compression-level: 1
# Settings for chunks that are not loaded when a screenshot is requested.
chunks:
# How chunks that are not loaded are handled while a screenshot is taken.
diff --git a/src/main/java/dev/loat/msmp_vision/MSMPVision.java b/src/main/java/dev/loat/msmp_vision/MSMPVision.java
index 48cdf0d..6eb9f66 100644
--- a/src/main/java/dev/loat/msmp_vision/MSMPVision.java
+++ b/src/main/java/dev/loat/msmp_vision/MSMPVision.java
@@ -7,7 +7,6 @@
import dev.loat.msmp_vision.msmp.endpoints.Endpoints;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
-import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents;
import net.minecraft.server.jsonrpc.ManagementServer;
/**
@@ -48,8 +47,6 @@ public void onInitialize() {
this.service.start();
});
- ServerTickEvents.END_SERVER_TICK.register(this.service::tick);
-
ServerLifecycleEvents.SERVER_STOPPED.register(server -> {
this.service.stop();
NAMESPACE.detach();
diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java
index 510c69f..9e121db 100644
--- a/src/main/java/dev/loat/msmp_vision/VisionService.java
+++ b/src/main/java/dev/loat/msmp_vision/VisionService.java
@@ -7,10 +7,8 @@
import dev.loat.msmp_vision.encode.ImageEncoder;
import dev.loat.msmp_vision.logging.Logger;
import dev.loat.msmp_vision.mesh.TintResolver;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotNotifier;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotResponse;
-import dev.loat.msmp_vision.msmp.exceptions.QueueFullException;
import dev.loat.msmp_vision.msmp.exceptions.UnknownDimensionException;
import dev.loat.msmp_vision.render.Camera;
import dev.loat.msmp_vision.render.RenderExecutor;
@@ -28,64 +26,23 @@
import java.nio.file.Path;
import java.util.Optional;
-import java.util.Queue;
-import java.util.UUID;
import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicInteger;
/**
* Handles screenshot requests: validates, captures the world on the server thread and renders on worker threads.
- *
- * Two ways to take a screenshot:
- *
- * - {@link #screenshot}: synchronous. Blocks the server thread until the image is ready.
- * - {@link #queue} + {@link #tick}: asynchronous. The world is captured in small slices over several ticks,
- * rendering and PNG encoding happen off the server thread and the result is sent as a notification.
- *
*/
public final class VisionService {
private record Runtime(
RenderExecutor executor,
- ExecutorService coordinator,
SceneRenderer renderer,
CompletableFuture ready
) {}
- private record Prepared(RenderSettings settings, ServerLevel level, Camera camera) {}
-
- private static final class Job {
- final String id = UUID.randomUUID().toString();
- final Integer connectionId;
- final Prepared prepared;
- final long createdNanos = System.nanoTime();
- SnapshotSession session;
-
- Job(Integer connectionId, Prepared prepared) {
- this.connectionId = connectionId;
- this.prepared = prepared;
- }
- }
-
private volatile Runtime runtime;
- private volatile ScreenshotNotifier notifier;
- private final Queue queue = new ConcurrentLinkedQueue<>();
- private final AtomicInteger pending = new AtomicInteger();
-
- /**
- * Sets the notifier used to deliver queued screenshots. Call once during initialization.
- *
- * @param notifier The notifier.
- */
- public void bindNotifier(ScreenshotNotifier notifier) {
- this.notifier = notifier;
- }
/**
* Starts asset preparation and the render pool. Call on server start.
@@ -98,11 +55,6 @@ public void start() {
TextureStore textures = new TextureStore(assets);
RenderExecutor executor = new RenderExecutor(config.render.threads);
SceneRenderer renderer = new SceneRenderer(assets, textures, executor);
- ExecutorService coordinator = Executors.newSingleThreadExecutor(runnable -> {
- Thread thread = new Thread(runnable, "msmp-vision-coordinator");
- thread.setDaemon(true);
- return thread;
- });
CompletableFuture ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets));
ready.whenComplete((ignored, error) -> {
@@ -112,23 +64,20 @@ public void start() {
Logger.info("Client assets ready (" + executor.threads() + " render threads).");
}
});
- this.runtime = new Runtime(executor, coordinator, renderer, ready);
+ this.runtime = new Runtime(executor, renderer, ready);
}
- /** Stops the render pool and drops all queued screenshots. Call on server stop. */
+ /** Stops the render pool. Call on server stop. */
public void stop() {
Runtime current = this.runtime;
this.runtime = null;
- this.queue.clear();
- this.pending.set(0);
if (current != null) {
- current.coordinator().shutdownNow();
current.executor().shutdown();
}
}
/**
- * Renders a screenshot and blocks until it is ready.
+ * Renders a screenshot.
*
* @param server The server.
* @param request The request.
@@ -138,122 +87,6 @@ public void stop() {
* @throws IllegalStateException if the renderer is not ready or rendering failed.
*/
public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) {
- Runtime current = this.requireReady();
- MSMPVisionConfigFile config = Config.getConfig();
- Prepared prepared = this.prepare(server, request, config);
-
- long start = System.nanoTime();
- SnapshotSession session = this.captureBlocking(server, prepared, config);
- Rendered rendered = current.renderer().render(session.snapshot(), prepared.camera(), prepared.settings(), config);
- long encodeStart = System.nanoTime();
- String encoded = ImageEncoder.toBase64Png(rendered.image());
- long end = System.nanoTime();
-
- logTimings(session.workNanos(), rendered, end - encodeStart, end - start);
- return new ScreenshotResponse(encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - start) / 1_000_000L));
- }
-
- /**
- * Queues a screenshot. The call returns immediately; the result is delivered with a notification to the
- * given connection, see {@link #tick(MinecraftServer)}.
- *
- * @param server The server.
- * @param connectionId The connection to notify.
- * @param request The request.
- * @return The id of the queued screenshot.
- * @throws QueueFullException if too many screenshots are pending.
- * @throws dev.loat.msmp_vision.msmp.exceptions.InvalidParamsException for invalid parameters.
- * @throws UnknownDimensionException if the requested dimension does not exist.
- * @throws IllegalStateException if the renderer is not ready.
- */
- public String queue(MinecraftServer server, Integer connectionId, ScreenshotRequest request) {
- this.requireReady();
- if (this.notifier == null) {
- throw new IllegalStateException("Notifications are not available.");
- }
- MSMPVisionConfigFile config = Config.getConfig();
- int limit = config.limits.maxPendingScreenshots;
- if (this.pending.get() >= limit) {
- throw new QueueFullException(limit);
- }
-
- Job job = new Job(connectionId, this.prepare(server, request, config));
- this.pending.incrementAndGet();
- this.queue.add(job);
- return job.id;
- }
-
- /**
- * Advances the oldest queued screenshot by one slice. Call once per server tick (on the server thread).
- *
- * @param server The server.
- */
- public void tick(MinecraftServer server) {
- Job job = this.queue.peek();
- if (job == null) {
- return;
- }
- try {
- Runtime current = this.runtime;
- if (current == null) {
- throw new IllegalStateException("The renderer is not running.");
- }
- MSMPVisionConfigFile config = Config.getConfig();
- long timeoutNanos = TimeUnit.SECONDS.toNanos(config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds);
- if (System.nanoTime() - job.createdNanos > timeoutNanos) {
- throw new IllegalStateException("Timed out while capturing the world.");
- }
- if (job.session == null) {
- job.session = new SnapshotSession(job.prepared.level(), job.prepared.camera(), job.prepared.settings(), config.chunks);
- }
- long budget = TimeUnit.MILLISECONDS.toNanos(Math.max(1, config.chunks.tickBudgetMs));
- if (!job.session.step(budget)) {
- return;
- }
- this.queue.remove(job);
- this.render(server, current, job);
- } catch (RuntimeException e) {
- this.queue.remove(job);
- this.fail(job, e);
- }
- }
-
- /** Hands a captured screenshot over to the coordinator thread, which renders and encodes it. */
- private void render(MinecraftServer server, Runtime current, Job job) {
- SnapshotSession session = job.session;
- current.coordinator().execute(() -> {
- try {
- MSMPVisionConfigFile config = Config.getConfig();
- Rendered rendered = current.renderer().render(session.snapshot(), job.prepared.camera(), job.prepared.settings(), config);
- long encodeStart = System.nanoTime();
- String encoded = ImageEncoder.toBase64Png(rendered.image());
- long end = System.nanoTime();
-
- logTimings(session.workNanos(), rendered, end - encodeStart, end - job.createdNanos);
- ScreenshotResponse response = new ScreenshotResponse(
- encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - job.createdNanos) / 1_000_000L)
- );
- server.execute(() -> {
- this.pending.decrementAndGet();
- this.notifier.completed(job.connectionId, job.id, response);
- });
- } catch (Throwable error) {
- server.execute(() -> this.fail(job, error));
- }
- });
- }
-
- private void fail(Job job, Throwable error) {
- this.pending.decrementAndGet();
- String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
- Logger.warning("Queued screenshot %s failed: %s".formatted(job.id, message));
- ScreenshotNotifier current = this.notifier;
- if (current != null) {
- current.failed(job.connectionId, job.id, message);
- }
- }
-
- private Runtime requireReady() {
Runtime current = this.runtime;
if (current == null) {
throw new IllegalStateException("The renderer is not running.");
@@ -264,26 +97,37 @@ private Runtime requireReady() {
if (current.ready().isCompletedExceptionally()) {
throw new IllegalStateException("Client assets could not be prepared, see the server log.");
}
- return current;
- }
- private Prepared prepare(MinecraftServer server, ScreenshotRequest request, MSMPVisionConfigFile config) {
+ MSMPVisionConfigFile config = Config.getConfig();
RenderSettings settings = RenderSettings.from(request, config);
ServerLevel level = this.resolveLevel(server, request.dimension());
- return new Prepared(settings, level, new Camera(settings));
+
+ long start = System.nanoTime();
+ Camera camera = new Camera(settings);
+ SnapshotSession session = this.capture(server, level, camera, settings, config);
+ Rendered rendered = current.renderer().render(session.snapshot(), camera, settings, config);
+ long encodeStart = System.nanoTime();
+ String encoded = ImageEncoder.toBase64Png(rendered.image(), config.render.pngCompressionLevel);
+ long end = System.nanoTime();
+
+ Logger.info("Screenshot timings: capture %d ms (server thread), mesh %d ms, sky %d ms, raster %d ms, downsample %d ms, encode %d ms, total %d ms."
+ .formatted(session.workNanos() / 1_000_000L, rendered.meshNanos() / 1_000_000L, rendered.skyNanos() / 1_000_000L,
+ rendered.tileNanos() / 1_000_000L, rendered.downsampleNanos() / 1_000_000L, (end - encodeStart) / 1_000_000L,
+ (end - start) / 1_000_000L));
+ return new ScreenshotResponse(encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - start) / 1_000_000L));
}
/**
* Captures the whole world at once on the server thread. MSMP handlers already run there, in which case the
* capture (including loading missing chunks) is done directly; otherwise it is scheduled on the server thread.
*/
- private SnapshotSession captureBlocking(MinecraftServer server, Prepared prepared, MSMPVisionConfigFile config) {
+ private SnapshotSession capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
if (server.isSameThread()) {
- return captureAll(prepared, config);
+ return captureAll(level, camera, settings, config);
}
long timeoutSeconds = config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds;
try {
- return CompletableFuture.supplyAsync(() -> captureAll(prepared, config), server).get(timeoutSeconds, TimeUnit.SECONDS);
+ return CompletableFuture.supplyAsync(() -> captureAll(level, camera, settings, config), server).get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("The server thread did not respond in time.");
} catch (ExecutionException e) {
@@ -294,18 +138,12 @@ private SnapshotSession captureBlocking(MinecraftServer server, Prepared prepare
}
}
- private static SnapshotSession captureAll(Prepared prepared, MSMPVisionConfigFile config) {
- SnapshotSession session = new SnapshotSession(prepared.level(), prepared.camera(), prepared.settings(), config.chunks);
+ private static SnapshotSession captureAll(ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) {
+ SnapshotSession session = new SnapshotSession(level, camera, settings, config.chunks);
session.step(Long.MAX_VALUE);
return session;
}
- private static void logTimings(long captureNanos, Rendered rendered, long encodeNanos, long totalNanos) {
- Logger.info("Screenshot timings: capture %d ms (server thread), mesh %d ms, raster %d ms, encode %d ms, total %d ms."
- .formatted(captureNanos / 1_000_000L, rendered.meshNanos() / 1_000_000L, rendered.rasterNanos() / 1_000_000L,
- encodeNanos / 1_000_000L, totalNanos / 1_000_000L));
- }
-
private ServerLevel resolveLevel(MinecraftServer server, Optional dimension) {
if (dimension.isEmpty()) {
return server.overworld();
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
index 42f3e78..5265836 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java
@@ -26,13 +26,6 @@ Maximum number of chunks that may be loaded (or generated) for a single request.
@Annotation.Key("load-timeout-seconds")
public int loadTimeoutSeconds = 10;
- @Annotation.Comment("""
- Maximum time in milliseconds per server tick that is spent on loading chunks for queued screenshots
- (vision:screenshot/queue). Lower values keep the server smoother but make queued screenshots take longer.
- """)
- @Annotation.Key("tick-budget-ms")
- public int tickBudgetMs = 15;
-
public static enum Mode {
NONE,
LOAD
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
index 40c278c..f9d83c2 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java
@@ -32,8 +32,4 @@ public class LimitsConfig {
@Annotation.Comment("Maximum field of view in degrees.")
@Annotation.Key("max-fov")
public double maxFov = 110.0;
-
- @Annotation.Comment("Maximum number of queued screenshots (vision:screenshot/queue) that may be pending at the same time.")
- @Annotation.Key("max-pending-screenshots")
- public int maxPendingScreenshots = 4;
}
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
index b4dbf88..e160519 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
@@ -22,4 +22,11 @@ Number of render worker threads (meshing and rasterization).
@Annotation.Comment("Skip back-facing triangles. Disable only for debugging.")
@Annotation.Key("backface-culling")
public boolean backfaceCulling = true;
+
+ @Annotation.Comment("""
+ Deflate level of the PNG encoder, 0 (no compression) to 9 (smallest file).
+ 1 encodes about 2.5x faster than 6 for a file that is roughly 30% larger.
+ """)
+ @Annotation.Key("png-compression-level")
+ public int pngCompressionLevel = 1;
}
diff --git a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
index 59aacf2..7223db8 100644
--- a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
+++ b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
@@ -1,34 +1,120 @@
package dev.loat.msmp_vision.encode;
-import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Base64;
+import java.util.zip.CRC32;
+import java.util.zip.Deflater;
/**
* Encodes rendered images for transport.
+ *
+ * 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.end();
+ }
+
+ 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/msmp/endpoints/Endpoints.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java
index e0e4af8..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
@@ -4,10 +4,6 @@
import dev.loat.msmp.MSMPServer;
import dev.loat.msmp_vision.VisionService;
import dev.loat.msmp_vision.msmp.endpoints.screenshot.Screenshot;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotNotifier;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed.NotificationScreenshotCompleted;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed.NotificationScreenshotFailed;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.queue.ScreenshotQueue;
import java.util.function.Supplier;
@@ -30,12 +26,5 @@ private Endpoints() {}
public static void register(MSMPNamespace namespace, Supplier msmpServer, VisionService service) {
Screenshot.register(namespace, service);
-
- service.bindNotifier(new ScreenshotNotifier(
- NotificationScreenshotCompleted.register(namespace),
- NotificationScreenshotFailed.register(namespace),
- msmpServer
- ));
- ScreenshotQueue.register(namespace, service);
}
}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
index 8ff27b7..7f3f520 100644
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
+++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java
@@ -4,7 +4,6 @@
import dev.loat.msmp.MSMPNamespace;
import dev.loat.msmp_vision.VisionService;
-import dev.loat.msmp_vision.config.Config;
import dev.loat.msmp_vision.logging.RPCConnectionLogger;
@@ -65,11 +64,10 @@ public static void register(MSMPNamespace namespace, VisionService service) {
ScreenshotResponse response = service.screenshot(server, params);
RPCConnectionLogger.info(client.connectionId(),
String.format(Locale.ROOT,
- "vision:screenshot - rendered %dx%d in %dms on %d threads at (%f, %f, %f)",
+ "vision:screenshot - rendered %dx%d in %dms at (%f, %f, %f)",
response.width(),
response.height(),
response.renderTimeMs(),
- Config.getConfig().render.threads,
params.position().get(0),
params.position().get(1),
params.position().get(2)
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 c6f6a6d..fc049f6 100644
--- a/src/main/java/dev/loat/msmp_vision/raster/Fog.java
+++ b/src/main/java/dev/loat/msmp_vision/raster/Fog.java
@@ -17,6 +17,7 @@ public final class Fog {
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;
@@ -28,6 +29,8 @@ private Fog(float start, float end, int r, int g, int b, float tanX, float tanY,
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);
}
/**
@@ -64,6 +67,9 @@ public static Fog of(float start, float end, int rgb, float tanHalfFov, float as
* @return Visibility 1 (no fog) to 0 (full fog).
*/
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);
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/render/Rendered.java b/src/main/java/dev/loat/msmp_vision/render/Rendered.java
index 537f765..adce70e 100644
--- a/src/main/java/dev/loat/msmp_vision/render/Rendered.java
+++ b/src/main/java/dev/loat/msmp_vision/render/Rendered.java
@@ -7,6 +7,8 @@
*
* @param image The final image.
* @param meshNanos Time spent meshing and projecting all sections.
- * @param rasterNanos Time spent painting the sky, rasterizing and downsampling.
+ * @param skyNanos Time spent painting the sky.
+ * @param tileNanos Time spent rasterizing the tiles.
+ * @param downsampleNanos Time spent downsampling the supersampled framebuffer.
*/
-public record Rendered(BufferedImage image, long meshNanos, long rasterNanos) {}
+public record Rendered(BufferedImage image, long meshNanos, long skyNanos, long tileNanos, long downsampleNanos) {}
diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
index 3d87d78..dafe511 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
@@ -88,16 +88,19 @@ public Rendered render(WorldSnapshot world, Camera camera, RenderSettings settin
}
Framebuffer framebuffer = new Framebuffer(width, height);
- SkyPainter.paint(framebuffer, camera, world.environment());
+ long skyStart = System.nanoTime();
+ SkyPainter.paint(framebuffer, camera, world.environment(), this.executor, timeout);
+ long skyDone = System.nanoTime();
float fogEnd = settings.renderDistanceBlocks();
Fog fog = Fog.of((float) (fogEnd * config.render.fogStartFactor), fogEnd, world.environment().fogColor(),
camera.tanHalfFov(), camera.aspect(), width, height);
new TileRasterizer(this.executor, config.render.tileSize, timeout)
.rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog);
+ long tilesDone = System.nanoTime();
BufferedImage image = framebuffer.toImage(settings.ssaa());
long doneNanos = System.nanoTime();
- return new Rendered(image, meshedNanos - startNanos, doneNanos - meshedNanos);
+ return new Rendered(image, meshedNanos - startNanos, skyDone - skyStart, tilesDone - skyDone, doneNanos - tilesDone);
}
private TextureData[] texturesById() {
diff --git a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
index fa1e316..9c8018c 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java
@@ -4,7 +4,10 @@
import dev.loat.msmp_vision.world.EnvironmentSnapshot;
import org.joml.Vector3f;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
/**
* Fills the framebuffer with a sky gradient, sun and moon. The horizon colour equals the fog colour so that
@@ -19,19 +22,21 @@ public final class SkyPainter {
private static final float SUN_HALF = 0.15f;
private static final float MOON_HALF = 0.10f;
+ /** Rows per parallel job. */
+ private static final int BAND_ROWS = 32;
+
private SkyPainter() {}
/**
- * Paints the sky and clears depth.
+ * Paints the sky and clears depth. The image is split into bands of rows that are painted in parallel.
*
* @param target The framebuffer.
* @param camera The camera.
* @param environment Environment values.
+ * @param executor Worker pool.
+ * @param timeoutSeconds Maximum time for the whole sky.
*/
- public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment) {
- int width = target.width();
- int height = target.height();
- int[] color = target.color();
+ public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment, RenderExecutor executor, long timeoutSeconds) {
Arrays.fill(target.depth(), 0f);
float[] horizon = rgb(environment.fogColor());
@@ -41,8 +46,24 @@ public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot
Vector3f sun = new Vector3f((float) -Math.sin(angle), (float) Math.cos(angle), 0).normalize();
Vector3f tangent = new Vector3f((float) Math.cos(angle), (float) Math.sin(angle), 0);
+ int height = target.height();
+ List> jobs = new ArrayList<>();
+ for (int from = 0; from < height; from += BAND_ROWS) {
+ int start = from;
+ int end = Math.min(height, from + BAND_ROWS);
+ jobs.add(CompletableFuture.runAsync(
+ () -> paintRows(target, camera, horizon, zenith, sun, tangent, start, end), executor.executor()));
+ }
+ executor.awaitAll(jobs, timeoutSeconds);
+ }
+
+ private static void paintRows(Framebuffer target, Camera camera, float[] horizon, float[] zenith,
+ Vector3f sun, Vector3f tangent, int fromRow, int toRow) {
+ int width = target.width();
+ int height = target.height();
+ int[] color = target.color();
Vector3f dir = new Vector3f();
- for (int y = 0; y < height; y++) {
+ for (int y = fromRow; y < toRow; y++) {
float ndcY = 1f - 2f * (y + 0.5f) / height;
for (int x = 0; x < width; x++) {
float ndcX = 2f * (x + 0.5f) / width - 1f;
From 713ae5c5e86f78c49a275aa8a0e12663c1edb7a9 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:11:46 +0100
Subject: [PATCH 15/18] fix: lighting
---
README.md | 4 +
.../config/files/render/RenderConfig.java | 7 +
.../dev/loat/msmp_vision/mesh/QuadBuffer.java | 67 +++++--
.../loat/msmp_vision/mesh/VertexLighting.java | 179 ++++++++++++++++++
.../screenshot/ScreenshotNotifier.java | 64 -------
.../NotificationScreenshotCompleted.java | 29 ---
...otificationScreenshotCompletedPayload.java | 50 -----
.../failed/NotificationScreenshotFailed.java | 29 ---
.../NotificationScreenshotFailedPayload.java | 31 ---
.../screenshot/queue/ScreenshotQueue.java | 59 ------
.../queue/ScreenshotQueueResponse.java | 32 ----
.../msmp/exceptions/QueueFullException.java | 14 --
.../msmp_vision/raster/GeometryProjector.java | 17 +-
.../msmp_vision/raster/TileRasterizer.java | 6 +-
14 files changed, 246 insertions(+), 342 deletions(-)
create mode 100644 src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
delete mode 100644 src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
diff --git a/README.md b/README.md
index b3ace23..66c3ce0 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,10 @@ rendering:
# Fog starts at this fraction of the render distance. Minecraft itself only fogs the last ~10%.
# @default: 0.9
fog-start-factor: 0.9
+ # Smooth lighting and ambient occlusion like Minecraft's "Smooth Lighting" option:
+ # light is blended across each block face and corners are darkened. Disable for flat lighting, which meshes a bit faster.
+ # @default: true
+ smooth-lighting: true
# Skip back-facing triangles. Disable only for debugging.
# @default: true
backface-culling: true
diff --git a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
index e160519..2a493f0 100644
--- a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
+++ b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java
@@ -19,6 +19,13 @@ Number of render worker threads (meshing and rasterization).
@Annotation.Key("fog-start-factor")
public double fogStartFactor = 0.9;
+ @Annotation.Comment("""
+ Smooth lighting and ambient occlusion like Minecraft's \"Smooth Lighting\" option:
+ light is blended across each block face and corners are darkened. Disable for flat lighting, which meshes a bit faster.
+ """)
+ @Annotation.Key("smooth-lighting")
+ public boolean smoothLighting = true;
+
@Annotation.Comment("Skip back-facing triangles. Disable only for debugging.")
@Annotation.Key("backface-culling")
public boolean backfaceCulling = true;
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/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/screenshot/ScreenshotNotifier.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
deleted file mode 100644
index 889acf4..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotNotifier.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot;
-
-import dev.loat.msmp.MSMPNotification;
-import dev.loat.msmp.MSMPServer;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed.NotificationScreenshotCompletedPayload;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed.NotificationScreenshotFailedPayload;
-
-import java.util.function.Supplier;
-
-
-/**
- * Sends the results of queued screenshots to the connection that requested them.
- *
- * Must be used on the server thread.
- */
-public final class ScreenshotNotifier {
-
- private final MSMPNotification completed;
- private final MSMPNotification failed;
- private final Supplier msmpServer;
-
- /**
- * @param completed The completion notification
- * @param failed The failure notification
- * @param msmpServer A supplier for the running {@link MSMPServer}, {@code null} when no server is running
- */
- public ScreenshotNotifier(
- MSMPNotification completed,
- MSMPNotification failed,
- Supplier msmpServer
- ) {
- this.completed = completed;
- this.failed = failed;
- this.msmpServer = msmpServer;
- }
-
- /**
- * Sends a finished screenshot.
- *
- * @param connectionId The connection that queued the screenshot
- * @param id The screenshot id
- * @param response The rendered image
- */
- public void completed(Integer connectionId, String id, ScreenshotResponse response) {
- MSMPServer server = this.msmpServer.get();
- if (server == null) return;
- server.sendTo(connectionId, this.completed, new NotificationScreenshotCompletedPayload(
- id, response.image(), response.format(), response.width(), response.height(), response.renderTimeMs()
- ));
- }
-
- /**
- * Sends a failure.
- *
- * @param connectionId The connection that queued the screenshot
- * @param id The screenshot id
- * @param message What went wrong
- */
- public void failed(Integer connectionId, String id, String message) {
- MSMPServer server = this.msmpServer.get();
- if (server == null) return;
- server.sendTo(connectionId, this.failed, new NotificationScreenshotFailedPayload(id, message));
- }
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
deleted file mode 100644
index a2a30ac..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompleted.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed;
-
-import dev.loat.msmp.MSMPNamespace;
-import dev.loat.msmp.MSMPNotification;
-
-
-/**
- * Registers the {@code vision:notification/screenshot/completed} MSMP notification.
- *
- * Sent to the connection that queued a screenshot with {@code vision:screenshot/queue}
- * as soon as the image is ready. Not broadcast to other connections.
- */
-public class NotificationScreenshotCompleted {
-
- private NotificationScreenshotCompleted() {}
-
- /**
- * Registers the notification.
- *
- * @param namespace The namespace to register this notification under
- * @return The registered notification, used to send it
- */
- public static MSMPNotification register(MSMPNamespace namespace) {
- return namespace.notification("screenshot/completed")
- .description("Sent to the requesting connection when a queued screenshot is ready")
- .responseSchema(NotificationScreenshotCompletedPayload.SCHEMA)
- .register();
- }
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
deleted file mode 100644
index 49a298b..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/completed/NotificationScreenshotCompletedPayload.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.completed;
-
-import com.mojang.serialization.Codec;
-import com.mojang.serialization.codecs.RecordCodecBuilder;
-import net.minecraft.server.jsonrpc.api.Schema;
-
-
-/**
- * Payload for the {@code vision:notification/screenshot/completed} notification.
- *
- * Example JSON representation:
- *
- * {
- * "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10",
- * "image": "iVBORw0KGgoAAAANSUhEUg...",
- * "format": "png",
- * "width": 512,
- * "height": 288,
- * "render_time_ms": 1240
- * }
- *
- *
- * @param id Identifier returned by {@code vision:screenshot/queue}
- * @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 Time from queuing until the image was ready, in milliseconds
- */
-public record NotificationScreenshotCompletedPayload(String id, String image, String format, int width, int height, int renderTimeMs) {
-
- /** Codec for serializing and deserializing {@link NotificationScreenshotCompletedPayload} instances. */
- public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
- Codec.STRING.fieldOf("id").forGetter(NotificationScreenshotCompletedPayload::id),
- Codec.STRING.fieldOf("image").forGetter(NotificationScreenshotCompletedPayload::image),
- Codec.STRING.fieldOf("format").forGetter(NotificationScreenshotCompletedPayload::format),
- Codec.INT.fieldOf("width").forGetter(NotificationScreenshotCompletedPayload::width),
- Codec.INT.fieldOf("height").forGetter(NotificationScreenshotCompletedPayload::height),
- Codec.INT.fieldOf("render_time_ms").forGetter(NotificationScreenshotCompletedPayload::renderTimeMs)
- ).apply(i, NotificationScreenshotCompletedPayload::new));
-
- /** MSMP schema for {@link NotificationScreenshotCompletedPayload}, used for protocol discovery. */
- public static final Schema SCHEMA = Schema.record(CODEC)
- .withField("id", Schema.STRING_SCHEMA)
- .withField("image", Schema.STRING_SCHEMA)
- .withField("format", Schema.STRING_SCHEMA)
- .withField("width", Schema.INT_SCHEMA)
- .withField("height", Schema.INT_SCHEMA)
- .withField("render_time_ms", Schema.INT_SCHEMA);
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
deleted file mode 100644
index 9c28099..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailed.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed;
-
-import dev.loat.msmp.MSMPNamespace;
-import dev.loat.msmp.MSMPNotification;
-
-
-/**
- * Registers the {@code vision:notification/screenshot/failed} MSMP notification.
- *
- * Sent to the connection that queued a screenshot with {@code vision:screenshot/queue}
- * when the screenshot could not be completed. Not broadcast to other connections.
- */
-public class NotificationScreenshotFailed {
-
- private NotificationScreenshotFailed() {}
-
- /**
- * Registers the notification.
- *
- * @param namespace The namespace to register this notification under
- * @return The registered notification, used to send it
- */
- public static MSMPNotification register(MSMPNamespace namespace) {
- return namespace.notification("screenshot/failed")
- .description("Sent to the requesting connection when a queued screenshot failed")
- .responseSchema(NotificationScreenshotFailedPayload.SCHEMA)
- .register();
- }
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
deleted file mode 100644
index 0f25c43..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/notification/failed/NotificationScreenshotFailedPayload.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.notification.failed;
-
-import com.mojang.serialization.Codec;
-import com.mojang.serialization.codecs.RecordCodecBuilder;
-import net.minecraft.server.jsonrpc.api.Schema;
-
-
-/**
- * Payload for the {@code vision:notification/screenshot/failed} notification.
- *
- * Example JSON representation:
- *
- * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10", "message": "Rendering timed out after 30s" }
- *
- *
- * @param id Identifier returned by {@code vision:screenshot/queue}
- * @param message A human-readable description of what went wrong
- */
-public record NotificationScreenshotFailedPayload(String id, String message) {
-
- /** Codec for serializing and deserializing {@link NotificationScreenshotFailedPayload} instances. */
- public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
- Codec.STRING.fieldOf("id").forGetter(NotificationScreenshotFailedPayload::id),
- Codec.STRING.fieldOf("message").forGetter(NotificationScreenshotFailedPayload::message)
- ).apply(i, NotificationScreenshotFailedPayload::new));
-
- /** MSMP schema for {@link NotificationScreenshotFailedPayload}, used for protocol discovery. */
- public static final Schema SCHEMA = Schema.record(CODEC)
- .withField("id", Schema.STRING_SCHEMA)
- .withField("message", Schema.STRING_SCHEMA);
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
deleted file mode 100644
index bca0bbb..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueue.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.queue;
-
-import dev.loat.msmp.MSMPNamespace;
-import dev.loat.msmp_vision.VisionService;
-import dev.loat.msmp_vision.logging.RPCConnectionLogger;
-import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest;
-
-
-/**
- * Registers the {@code vision:screenshot/queue} MSMP method.
- *
- * Same parameters as {@code vision:screenshot}, but the call returns immediately with an {@code id}.
- * The world is captured in small slices over several server ticks, rendered on worker threads and the result is
- * delivered with the {@code vision:notification/screenshot/completed} (or {@code .../failed}) notification to the
- * connection that queued it. In contrast to {@code vision:screenshot}, the server keeps ticking meanwhile.
- *
- * Example request:
- *
- * {
- * "jsonrpc": "2.0",
- * "id": 1,
- * "method": "vision:screenshot/queue",
- * "params": [{ "position": [128.5, 80.0, -32.3], "rotation": [90.0, 0.0] }]
- * }
- *
- *
- * Example response:
- *
- * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10" }
- *
- */
-public class ScreenshotQueue {
-
- private ScreenshotQueue() {}
-
- /**
- * Registers the {@code vision:screenshot/queue} method on the given {@link MSMPNamespace}.
- *
- * @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/queue")
- .description("Queues a server-side screenshot and returns its id immediately; the image is delivered with a notification")
- .requestSchema(ScreenshotRequest.SCHEMA)
- .responseSchema(ScreenshotQueueResponse.SCHEMA)
- .register((server, client, params) -> {
- try {
- String id = service.queue(server, client.connectionId(), params);
- RPCConnectionLogger.info(client.connectionId(), "vision:screenshot/queue - queued " + id);
- return new ScreenshotQueueResponse(id);
- } catch (RuntimeException e) {
- RPCConnectionLogger.warning(client.connectionId(), "vision:screenshot/queue - " + e.getMessage());
- throw e;
- }
- });
- }
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
deleted file mode 100644
index 5242313..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/queue/ScreenshotQueueResponse.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package dev.loat.msmp_vision.msmp.endpoints.screenshot.queue;
-
-import com.mojang.serialization.Codec;
-import com.mojang.serialization.codecs.RecordCodecBuilder;
-import net.minecraft.server.jsonrpc.api.Schema;
-
-
-/**
- * Response payload for the {@code vision:screenshot/queue} method.
- *
- * Example JSON representation:
- *
- * { "id": "5b1c3c6e-8c53-4f43-9d5c-4a8f2f6a7c10" }
- *
- *
- * @param id Identifier of the queued screenshot, repeated in the completion notification
- */
-public record ScreenshotQueueResponse(String id) {
-
- /**
- * Codec for serializing and deserializing {@link ScreenshotQueueResponse} instances.
- */
- public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
- Codec.STRING.fieldOf("id").forGetter(ScreenshotQueueResponse::id)
- ).apply(i, ScreenshotQueueResponse::new));
-
- /**
- * MSMP schema for {@link ScreenshotQueueResponse}, used for protocol discovery.
- */
- public static final Schema SCHEMA = Schema.record(CODEC)
- .withField("id", Schema.STRING_SCHEMA);
-}
diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
deleted file mode 100644
index f7188b8..0000000
--- a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/QueueFullException.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package dev.loat.msmp_vision.msmp.exceptions;
-
-/**
- * Thrown when too many queued screenshots are pending.
- */
-public class QueueFullException extends MSMPException {
-
- /**
- * @param limit The configured maximum number of pending screenshots
- */
- public QueueFullException(int limit) {
- super("Too many pending screenshots (limit %d), try again later".formatted(limit));
- }
-}
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 56b45d6..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,9 +183,9 @@ 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 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)));
From 2977602869e60a3792c1af479f72ac979c151fa6 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Tue, 27 Oct 2026 23:13:46 +0100
Subject: [PATCH 16/18] fix: close
---
src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
index 7223db8..655c919 100644
--- a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
+++ b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java
@@ -86,7 +86,7 @@ static byte[] toPng(BufferedImage image, int level) throws IOException {
compressed.write(buffer, 0, n);
}
} finally {
- deflater.end();
+ deflater.close();
}
ByteArrayOutputStream out = new ByteArrayOutputStream(compressed.size() + 64);
From a7d7a33770bcf845468f2e198cd0f9ac0f877069 Mon Sep 17 00:00:00 2001
From: Mqxx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 28 Oct 2026 07:40:09 +0100
Subject: [PATCH 17/18] fix: lighting
---
.../loat/msmp_vision/mesh/SectionMesher.java | 81 +++++++++++++++++--
.../msmp_vision/render/SceneRenderer.java | 2 +-
2 files changed, 76 insertions(+), 7 deletions(-)
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 2270ce1..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.
*/
@@ -43,6 +46,10 @@ 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;
/**
@@ -54,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;
@@ -65,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);
}
/**
@@ -124,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 = multiply(quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF, this.debugTint);
- 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);
+ 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));
+ }
}
}
@@ -179,6 +203,51 @@ 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;
diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
index dafe511..255f0d2 100644
--- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
+++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java
@@ -71,7 +71,7 @@ public Rendered render(WorldSnapshot world, Camera camera, RenderSettings settin
continue;
}
jobs.add(CompletableFuture.supplyAsync(() -> {
- SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend()).mesh(section);
+ SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend(), config.render.smoothLighting).mesh(section);
GeometryProjector projector = new GeometryProjector(camera.viewProjection(), width, height, cull);
return new Projected(projector.project(geometry.opaque()), projector.project(geometry.translucent()));
}, this.executor.executor()));
From 209d49c5c09bc1c295b65b8c70e692931e267cc3 Mon Sep 17 00:00:00 2001
From: Mqx <62719703+Mqxx@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:51:49 +0100
Subject: [PATCH 18/18] bump version
---
gradle.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle.properties b/gradle.properties
index 45cbfe6..e833056 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -12,7 +12,7 @@ loom_version=1.16-SNAPSHOT
fabric_api_version=0.145.1+26.1
# Mod Properties
-mod_version=0.1.0
+mod_version=0.2.0
maven_group=dev.loat
archives_base_name=msmp-vision-mod