From a812a43b3b55d607a9b4a9cb523fca6ed7fcc301 Mon Sep 17 00:00:00 2001 From: Mqx <62719703+Mqxx@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:05:44 +0200 Subject: [PATCH 01/18] feat: basic files structure --- .../java/dev/loat/msmp_vision/MSMPVision.java | 27 ++++++- .../config/files/MSMPVisionConfigFile.java | 22 +++++- .../config/files/defaults/DefaultsConfig.java | 24 ++++++ .../config/files/limits/LimitsConfig.java | 27 +++++++ .../files/rendering/RenderingConfig.java | 15 ++++ .../config/files/threads/ThreadsConfig.java | 12 +++ .../msmp_vision/msmp/endpoints/Endpoints.java | 21 ++++++ .../msmp/endpoints/screenshot/Screenshot.java | 23 ++++++ .../screenshot/ScreenshotRequest.java | 73 +++++++++++++++++++ .../screenshot/ScreenshotResponse.java | 39 ++++++++++ 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java create mode 100644 src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java create mode 100644 src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java create mode 100644 src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java diff --git a/src/main/java/dev/loat/msmp_vision/MSMPVision.java b/src/main/java/dev/loat/msmp_vision/MSMPVision.java index 252abf6..91e1379 100644 --- a/src/main/java/dev/loat/msmp_vision/MSMPVision.java +++ b/src/main/java/dev/loat/msmp_vision/MSMPVision.java @@ -1,17 +1,36 @@ package dev.loat.msmp_vision; +import dev.loat.msmp.MSMPNamespace; +import dev.loat.msmp.MSMPServer; import dev.loat.msmp_vision.config.Config; import dev.loat.msmp_vision.logging.Logger; +import dev.loat.msmp_vision.msmp.endpoints.Endpoints; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.minecraft.server.jsonrpc.ManagementServer; /** * This class represents the main entry point for the MSMPVision mod. */ public class MSMPVision implements ModInitializer { + /** + * The shared {@code vision} namespace used for all MSMP registrations. + * Attached to the running server in {@code SERVER_STARTED} and detached in {@code SERVER_STOPPED}. + */ + private static final MSMPNamespace NS = new MSMPNamespace("vision"); + + /** + * Provides access to the {@link ManagementServer} + * for broadcasting notifications. {@code null} when no server is running. + */ + private static MSMPServer msmp; + /** * Called by Fabric during mod initialization, before the server starts. + * + *

Registers all MSMP methods and notifications and sets up server + * lifecycle hooks for attaching and detaching the namespace.

*/ @Override public void onInitialize() { @@ -19,12 +38,16 @@ public void onInitialize() { Config.register(); + Endpoints.register(NS, () -> msmp); + ServerLifecycleEvents.SERVER_STARTED.register(server -> { - + NS.attach(server); + msmp = new MSMPServer(server); }); ServerLifecycleEvents.SERVER_STOPPED.register(server -> { - + NS.detach(); + msmp = null; }); Logger.info("Mod initialized."); 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 73c1521..c9c5d92 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,15 +1,31 @@ package dev.loat.msmp_vision.config.files; 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; @Annotation.Comment(""" - This class represents the configuration file for the MSMPVision mod. + Main configuration file for MSMP Vision. """) public class MSMPVisionConfigFile { + private MSMPVisionConfigFile() {} - @Annotation.Comment("This is a sample configuration value.") - public String myString = "default_value"; + @Annotation.Comment("Thread settings of the renderer.") + public ThreadsConfig threads = new ThreadsConfig(); + + @Annotation.Comment("Values used when a request does not specify a setting.") + public DefaultsConfig defaults = new DefaultsConfig(); + + @Annotation.Comment("Upper limits which requests are clamped to.") + 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.") + public int requestTimeoutSeconds = 30; } 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 new file mode 100644 index 0000000..b1a5877 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java @@ -0,0 +1,24 @@ +package dev.loat.msmp_vision.config.files.defaults; + +import dev.loat.config_lib.annotation.Annotation; + + +public class DefaultsConfig { + @Annotation.Comment("Output width in pixels.") + public int width = 256; + + @Annotation.Comment("Output height in pixels.") + public int height = 256; + + @Annotation.Comment("Vertical field of view in degrees.") + public double fov = 70.0; + + @Annotation.Comment("Render distance in blocks.") + public int renderDistance = 64; + + @Annotation.Comment("Supersampling factor (1 = off).") + public int ssaa = 1; + + @Annotation.Comment("Biome tint blend radius in blocks (0 = off).") + public int biomeBlend = 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 new file mode 100644 index 0000000..7dd0f9f --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java @@ -0,0 +1,27 @@ +package dev.loat.msmp_vision.config.files.limits; + +import dev.loat.config_lib.annotation.Annotation; + + +public class LimitsConfig { + @Annotation.Comment("Maximum output width.") + public int maxWidth = 1920; + + @Annotation.Comment("Maximum output height.") + public int maxHeight = 1080; + + @Annotation.Comment("Maximum render distance in blocks.") + public int maxRenderDistance = 128; + + @Annotation.Comment("Maximum supersampling factor.") + public int maxSsaa = 3; + + @Annotation.Comment("Maximum number of internally rendered pixels (width * height * ssaa^2).") + public int maxRenderedPixels = 8_000_000; + + @Annotation.Comment("Minimum field of view in degrees.") + public double minFov = 30.0; + + @Annotation.Comment("Maximum field of view in degrees.") + public double maxFov = 110.0; +} 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/rendering/RenderingConfig.java new file mode 100644 index 0000000..a6800bc --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java @@ -0,0 +1,15 @@ +package dev.loat.msmp_vision.config.files.rendering; + +import dev.loat.config_lib.annotation.Annotation; + + +public class RenderingConfig { + @Annotation.Comment("Edge length of a raster tile in pixels. One tile is one parallel work unit.") + public int tileSize = 64; + + @Annotation.Comment("Fog starts at this fraction of the render distance.") + public double fogStartFactor = 0.75; + + @Annotation.Comment("Skip back-facing opaque triangles. Disable only for debugging.") + public boolean backfaceCulling = true; +} 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 new file mode 100644 index 0000000..4206a24 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java @@ -0,0 +1,12 @@ +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/Endpoints.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java new file mode 100644 index 0000000..c5b75ae --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java @@ -0,0 +1,21 @@ +package dev.loat.msmp_vision.msmp.endpoints; + +import dev.loat.msmp.MSMPNamespace; +import dev.loat.msmp.MSMPServer; + +import java.util.function.Supplier; + + +public class Endpoints { + 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 + */ + public static void register(MSMPNamespace namespace, Supplier msmpServer) { + + } +} 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 new file mode 100644 index 0000000..73f7cee --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java @@ -0,0 +1,23 @@ +package dev.loat.msmp_vision.msmp.endpoints.screenshot; + +import dev.loat.msmp.MSMPNamespace; + + +public class Screenshot { + + private Screenshot() {} + + /** + * Registers the methods. Must be called during mod initialization, before the registry freezes. + * + * @param namespace The MSMP namespace to register under. + * @param service The service handling the requests. + */ + public static void register(MSMPNamespace namespace) { + namespace.method("screenshot") + .description("Renders a server-side screenshot and returns it as a Base64 encoded PNG.") + .requestSchema(ScreenshotRequest.SCHEMA) + .responseSchema(ScreenshotResponse.SCHEMA) + .register((server, client, request) -> null); + } +} 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 new file mode 100644 index 0000000..e81d5ab --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java @@ -0,0 +1,73 @@ +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; + +/** + * Parameters of the {@code msmp_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}.

+ * + * @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. + */ +public record ScreenshotRequest( + String dimension, + List position, + float yaw, + float pitch, + int width, + int height, + double fov, + int renderDistance, + int ssaa, + int 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), + 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. */ + 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("width", Schema.INT_SCHEMA) + .withField("height", Schema.INT_SCHEMA) + .withField("fov", Schema.NUMBER_SCHEMA) + .withField("render_distance", Schema.INT_SCHEMA) + .withField("ssaa", Schema.INT_SCHEMA) + .withField("biome_blend", Schema.INT_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 new file mode 100644 index 0000000..6de4fb0 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java @@ -0,0 +1,39 @@ +package dev.loat.msmp_vision.msmp.endpoints.screenshot; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.server.jsonrpc.api.Schema; + + +/** + * Result of the {@code msmp_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. + */ +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( + 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)); + + /** + * MSMP schema for {@link ScreenshotResponse}, used for protocol discovery. + */ + public static final Schema SCHEMA = Schema.record(CODEC) + .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); +} From 9c5651402642e8adf27de1a68cbcffd5a00c7103 Mon Sep 17 00:00:00 2001 From: Mqx <62719703+Mqxx@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:47:46 +0200 Subject: [PATCH 02/18] feat: assets, model --- .../loat/msmp_vision/assets/AssetManager.java | 223 ++++++++++++++++++ .../loat/msmp_vision/assets/ResourceIds.java | 73 ++++++ .../loat/msmp_vision/assets/TextureData.java | 61 +++++ .../loat/msmp_vision/assets/TextureStore.java | 103 ++++++++ .../java/dev/loat/msmp_vision/mixin/.gitkeep | 0 .../loat/msmp_vision/mixin_registry/.gitkeep | 0 .../dev/loat/msmp_vision/model/BakedQuad.java | 26 ++ .../msmp_vision/model/BlockModelBaker.java | 191 +++++++++++++++ .../msmp_vision/model/BlockStateSelector.java | 153 ++++++++++++ .../loat/msmp_vision/model/FaceGeometry.java | 47 ++++ .../loat/msmp_vision/model/ModelResolver.java | 108 +++++++++ 11 files changed, 985 insertions(+) create mode 100644 src/main/java/dev/loat/msmp_vision/assets/AssetManager.java create mode 100644 src/main/java/dev/loat/msmp_vision/assets/ResourceIds.java create mode 100644 src/main/java/dev/loat/msmp_vision/assets/TextureData.java create mode 100644 src/main/java/dev/loat/msmp_vision/assets/TextureStore.java delete mode 100644 src/main/java/dev/loat/msmp_vision/mixin/.gitkeep delete mode 100644 src/main/java/dev/loat/msmp_vision/mixin_registry/.gitkeep create mode 100644 src/main/java/dev/loat/msmp_vision/model/BakedQuad.java create mode 100644 src/main/java/dev/loat/msmp_vision/model/BlockModelBaker.java create mode 100644 src/main/java/dev/loat/msmp_vision/model/BlockStateSelector.java create mode 100644 src/main/java/dev/loat/msmp_vision/model/FaceGeometry.java create mode 100644 src/main/java/dev/loat/msmp_vision/model/ModelResolver.java diff --git a/src/main/java/dev/loat/msmp_vision/assets/AssetManager.java b/src/main/java/dev/loat/msmp_vision/assets/AssetManager.java new file mode 100644 index 0000000..51e20a5 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/assets/AssetManager.java @@ -0,0 +1,223 @@ +package dev.loat.msmp_vision.assets; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import dev.loat.msmp_vision.logging.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Provides the vanilla client resources (blockstates, models, textures) on a dedicated server. + * + *

The official {@code client.jar} of the running Minecraft version is downloaded from Mojang once, + * the needed parts are extracted to disk and then read from there. No Mojang assets are shipped with the mod.

+ */ +public final class AssetManager { + + private static final String MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json"; + private static final String MARKER_FILE = ".version"; + private static final String ASSET_PREFIX = "assets/minecraft/"; + private static final List EXTRACTED_DIRECTORIES = List.of("blockstates/", "models/", "textures/"); + + private static final HttpClient HTTP = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final Path root; + private final String version; + private CompletableFuture ready; + + /** + * Creates a manager. + * + * @param root Directory the assets are extracted to. + * @param version Minecraft version whose client jar is used. + */ + public AssetManager(Path root, String version) { + this.root = root.toAbsolutePath().normalize(); + this.version = version; + } + + /** + * Starts downloading and extracting the assets in the background if not already done. + * Calling this multiple times returns the same future. + * + * @return A future completing when all assets are available. + */ + public synchronized CompletableFuture initialize() { + if (this.ready == null) { + this.ready = CompletableFuture.runAsync(this::prepare); + } + return this.ready; + } + + /** + * Reads a file below the extracted asset root. + * + * @param path Relative path such as {@code assets/minecraft/models/block/stone.json}. + * @return The file content, or {@code null} if it does not exist. + */ + public byte[] read(String path) { + Path file = this.root.resolve(path).normalize(); + if (!file.startsWith(this.root) || !Files.isRegularFile(file)) { + return null; + } + try { + return Files.readAllBytes(file); + } catch (IOException e) { + Logger.warning("Failed to read asset '%s': %s".formatted(path, e.getMessage())); + return null; + } + } + + /** + * Reads and parses a JSON file below the asset root. + * + * @param path Relative path. + * @return The parsed object, or {@code null} if missing or invalid. + */ + public JsonObject readJson(String path) { + byte[] bytes = this.read(path); + if (bytes == null) { + return null; + } + try { + JsonElement element = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + return element.isJsonObject() ? element.getAsJsonObject() : null; + } catch (RuntimeException e) { + Logger.warning("Invalid JSON in asset '%s': %s".formatted(path, e.getMessage())); + return null; + } + } + + private void prepare() { + try { + if (this.isUpToDate()) { + Logger.info("Client assets for %s are ready.".formatted(this.version)); + return; + } + this.downloadAndExtract(); + Files.writeString(this.root.resolve(MARKER_FILE), this.version); + Logger.info("Client assets for %s are ready.".formatted(this.version)); + } catch (IOException e) { + throw new CompletionException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CompletionException(e); + } + } + + private boolean isUpToDate() throws IOException { + Path marker = this.root.resolve(MARKER_FILE); + return Files.isRegularFile(marker) && this.version.equals(Files.readString(marker).trim()); + } + + private void downloadAndExtract() throws IOException, InterruptedException { + Files.createDirectories(this.root); + + JsonObject manifest = JsonParser.parseString(this.fetchString(MANIFEST_URL)).getAsJsonObject(); + String versionUrl = null; + for (JsonElement entry : manifest.getAsJsonArray("versions")) { + JsonObject object = entry.getAsJsonObject(); + if (object.get("id").getAsString().equals(this.version)) { + versionUrl = object.get("url").getAsString(); + break; + } + } + if (versionUrl == null) { + throw new IOException("Minecraft version '%s' not found in the Mojang version manifest.".formatted(this.version)); + } + + JsonObject client = JsonParser.parseString(this.fetchString(versionUrl)).getAsJsonObject() + .getAsJsonObject("downloads").getAsJsonObject("client"); + String url = client.get("url").getAsString(); + String sha1 = client.get("sha1").getAsString(); + + Path jar = this.root.resolve("client.jar"); + Logger.info("Downloading Minecraft client jar (%d MiB)...".formatted(client.get("size").getAsLong() / 1024 / 1024)); + this.download(url, jar, sha1); + + Logger.info("Extracting client assets..."); + try { + this.extract(jar); + } finally { + Files.deleteIfExists(jar); + } + } + + private String fetchString(String url) throws IOException, InterruptedException { + HttpResponse response = HTTP.send(HttpRequest.newBuilder(URI.create(url)).build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP %d while fetching %s".formatted(response.statusCode(), url)); + } + return response.body(); + } + + private void download(String url, Path target, String expectedSha1) throws IOException, InterruptedException { + HttpResponse response = HTTP.send(HttpRequest.newBuilder(URI.create(url)).build(), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() != 200) { + throw new IOException("HTTP %d while downloading %s".formatted(response.statusCode(), url)); + } + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + try (InputStream in = new DigestInputStream(response.body(), digest)) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + String actual = HexFormat.of().formatHex(digest.digest()); + if (!actual.equalsIgnoreCase(expectedSha1)) { + Files.deleteIfExists(target); + throw new IOException("Client jar checksum mismatch."); + } + } + + private void extract(Path jar) throws IOException { + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(jar))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + String name = entry.getName(); + if (entry.isDirectory() || !this.isExtracted(name)) { + continue; + } + Path target = this.root.resolve(name).normalize(); + if (!target.startsWith(this.root)) { + continue; // zip-slip guard + } + Files.createDirectories(target.getParent()); + Files.copy(zip, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private boolean isExtracted(String entryName) { + if (!entryName.startsWith(ASSET_PREFIX)) { + return false; + } + String relative = entryName.substring(ASSET_PREFIX.length()); + return EXTRACTED_DIRECTORIES.stream().anyMatch(relative::startsWith); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/assets/ResourceIds.java b/src/main/java/dev/loat/msmp_vision/assets/ResourceIds.java new file mode 100644 index 0000000..0e31d8c --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/assets/ResourceIds.java @@ -0,0 +1,73 @@ +package dev.loat.msmp_vision.assets; + +/** + * Helpers for resource identifiers of the form {@code namespace:path}. + */ +public final class ResourceIds { + + private static final String DEFAULT_NAMESPACE = "minecraft"; + + private ResourceIds() {} + + /** + * Adds the default namespace if none is present. + * + * @param id An identifier with or without namespace. + * @return The identifier including a namespace. + */ + public static String normalize(String id) { + return id.indexOf(':') >= 0 ? id : DEFAULT_NAMESPACE + ":" + id; + } + + /** + * Returns the namespace part of an identifier. + * + * @param id The identifier. + * @return The namespace. + */ + public static String namespace(String id) { + String normalized = normalize(id); + return normalized.substring(0, normalized.indexOf(':')); + } + + /** + * Returns the path part of an identifier. + * + * @param id The identifier. + * @return The path. + */ + public static String path(String id) { + String normalized = normalize(id); + return normalized.substring(normalized.indexOf(':') + 1); + } + + /** + * Returns the asset path of a model file. + * + * @param id The model identifier. + * @return The relative asset path. + */ + public static String modelPath(String id) { + return "assets/" + namespace(id) + "/models/" + path(id) + ".json"; + } + + /** + * Returns the asset path of a texture file. + * + * @param id The texture identifier. + * @return The relative asset path. + */ + public static String texturePath(String id) { + return "assets/" + namespace(id) + "/textures/" + path(id) + ".png"; + } + + /** + * Returns the asset path of a blockstate file. + * + * @param id The block identifier. + * @return The relative asset path. + */ + public static String blockStatePath(String id) { + return "assets/" + namespace(id) + "/blockstates/" + path(id) + ".json"; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/assets/TextureData.java b/src/main/java/dev/loat/msmp_vision/assets/TextureData.java new file mode 100644 index 0000000..fea34b6 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/assets/TextureData.java @@ -0,0 +1,61 @@ +package dev.loat.msmp_vision.assets; + +/** + * A decoded texture (first animation frame only) as ARGB pixels. + */ +public final class TextureData { + + private final int id; + private final int width; + private final int height; + private final int[] pixels; + private final boolean translucent; + + /** + * Creates a texture. + * + * @param id Index in the {@link TextureStore}. + * @param width Width in pixels. + * @param height Height in pixels. + * @param pixels ARGB pixels, row-major, {@code width * height} entries. + * @param translucent Whether any pixel has an alpha strictly between 0 and 255. + */ + public TextureData(int id, int width, int height, int[] pixels, boolean translucent) { + this.id = id; + this.width = width; + this.height = height; + this.pixels = pixels; + this.translucent = translucent; + } + + /** + * Returns the store index. + * + * @return The id. + */ + public int id() { + return this.id; + } + + /** + * Returns whether the texture needs alpha blending. + * + * @return {@code true} if translucent. + */ + public boolean translucent() { + return this.translucent; + } + + /** + * Samples the texture with nearest-neighbour filtering and repeat wrapping. + * + * @param u Horizontal coordinate in [0, 1]. + * @param v Vertical coordinate in [0, 1]. + * @return The ARGB pixel. + */ + public int sample(float u, float v) { + int x = Math.floorMod((int) Math.floor((double) u * this.width), this.width); + int y = Math.floorMod((int) Math.floor((double) v * this.height), this.height); + return this.pixels[y * this.width + x]; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/assets/TextureStore.java b/src/main/java/dev/loat/msmp_vision/assets/TextureStore.java new file mode 100644 index 0000000..657822c --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/assets/TextureStore.java @@ -0,0 +1,103 @@ +package dev.loat.msmp_vision.assets; + +import dev.loat.msmp_vision.logging.Logger; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe, lazily filled registry of textures. Every texture gets a stable integer id + * which is used by meshes and the rasterizer. + */ +public final class TextureStore { + + private final AssetManager assets; + private final ConcurrentHashMap byName = new ConcurrentHashMap<>(); + private final List registry = new ArrayList<>(); + + /** + * Creates a store. + * + * @param assets The asset source. + */ + public TextureStore(AssetManager assets) { + this.assets = assets; + } + + /** + * Returns the texture for an identifier, loading it on first use. + * A magenta/black placeholder is returned if the texture is missing. + * + * @param reference Texture identifier, e.g. {@code minecraft:block/stone} or {@code block/stone}. + * @return The texture, never {@code null}. + */ + public TextureData get(String reference) { + String name = ResourceIds.normalize(reference); + TextureData existing = this.byName.get(name); + if (existing != null) { + return existing; + } + return this.byName.computeIfAbsent(name, this::load); + } + + /** + * Returns all registered textures indexed by id. Call after meshing so every id is known. + * + * @return A copy of the registry. + */ + public TextureData[] snapshot() { + synchronized (this.registry) { + return this.registry.toArray(new TextureData[0]); + } + } + + private TextureData load(String name) { + byte[] bytes = this.assets.read(ResourceIds.texturePath(name)); + if (bytes != null) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image != null) { + int width = image.getWidth(); + int frameHeight = Math.min(image.getHeight(), width); // animated strips: first frame only + return this.register(width, frameHeight, image.getRGB(0, 0, width, frameHeight, null, 0, width)); + } + } catch (IOException e) { + Logger.warning("Failed to decode texture '%s': %s".formatted(name, e.getMessage())); + } + } else { + Logger.debug("Missing texture '%s'.".formatted(name)); + } + return this.register(16, 16, missingPixels()); + } + + private TextureData register(int width, int height, int[] pixels) { + boolean translucent = false; + for (int argb : pixels) { + int alpha = argb >>> 24; + if (alpha > 0 && alpha < 255) { + translucent = true; + break; + } + } + synchronized (this.registry) { + TextureData texture = new TextureData(this.registry.size(), width, height, pixels, translucent); + this.registry.add(texture); + return texture; + } + } + + private static int[] missingPixels() { + int[] pixels = new int[16 * 16]; + for (int y = 0; y < 16; y++) { + for (int x = 0; x < 16; x++) { + pixels[y * 16 + x] = ((x / 8 + y / 8) % 2 == 0) ? 0xFFF800F8 : 0xFF000000; + } + } + return pixels; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/mixin/.gitkeep b/src/main/java/dev/loat/msmp_vision/mixin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/dev/loat/msmp_vision/mixin_registry/.gitkeep b/src/main/java/dev/loat/msmp_vision/mixin_registry/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/dev/loat/msmp_vision/model/BakedQuad.java b/src/main/java/dev/loat/msmp_vision/model/BakedQuad.java new file mode 100644 index 0000000..f7cfe39 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/model/BakedQuad.java @@ -0,0 +1,26 @@ +package dev.loat.msmp_vision.model; + +import net.minecraft.core.Direction; + +/** + * A single textured quad of a baked block model in block-local space (0..1). + * + * @param positions 4 vertices x 3 floats, counter-clockwise when seen from outside. + * @param uvs 4 vertices x 2 floats in texture space (0..1). + * @param texture Texture id in the {@code TextureStore}. + * @param face Direction the quad faces after rotation; used for shading and light sampling. + * @param cullFace Direction in which an opaque neighbour hides this quad, or {@code null}. + * @param tintIndex Tint index of the model face, {@code -1} for none. + * @param shade Whether directional shading applies. + * @param translucent Whether the texture requires alpha blending. + */ +public record BakedQuad( + float[] positions, + float[] uvs, + int texture, + Direction face, + Direction cullFace, + int tintIndex, + boolean shade, + boolean translucent +) {} diff --git a/src/main/java/dev/loat/msmp_vision/model/BlockModelBaker.java b/src/main/java/dev/loat/msmp_vision/model/BlockModelBaker.java new file mode 100644 index 0000000..081b090 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/model/BlockModelBaker.java @@ -0,0 +1,191 @@ +package dev.loat.msmp_vision.model; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.assets.TextureStore; +import dev.loat.msmp_vision.model.BlockStateSelector.ModelVariant; +import dev.loat.msmp_vision.model.ModelResolver.ResolvedModel; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Turns a {@link BlockState} into a list of {@link BakedQuad}s (block-local coordinates) and caches the result. + * + *

Supported: variants, multipart, parent inheritance, element rotation (incl. rescale), block x/y rotation, + * UV rotation, tint indices, cull faces. Not yet supported: {@code uvlock}, item/entity-style builtin models.

+ */ +public final class BlockModelBaker { + + private static final String MISSING_TEXTURE = "minecraft:missing"; + + private final BlockStateSelector selector; + private final ModelResolver models; + private final TextureStore textures; + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + /** + * Creates a baker. + * + * @param selector Blockstate evaluator. + * @param models Model loader. + * @param textures Texture registry. + */ + public BlockModelBaker(BlockStateSelector selector, ModelResolver models, TextureStore textures) { + this.selector = selector; + this.models = models; + this.textures = textures; + } + + /** + * Returns the quads of a block state. + * + * @param state The block state. + * @return An immutable quad list, possibly empty. + */ + public List bake(BlockState state) { + return this.cache.computeIfAbsent(state, this::bakeUncached); + } + + private List bakeUncached(BlockState state) { + List quads = new ArrayList<>(); + for (ModelVariant variant : this.selector.select(state)) { + ResolvedModel model = this.models.resolve(variant.model()); + JsonArray elements = model.elements(); + if (elements == null) { + continue; + } + Quaternionf rotation = variant.x() == 0 && variant.y() == 0 ? null + : new Quaternionf().rotationYXZ(-(float) Math.toRadians(variant.y()), -(float) Math.toRadians(variant.x()), 0f); + for (JsonElement element : elements) { + this.bakeElement(element.getAsJsonObject(), model, rotation, quads); + } + } + return List.copyOf(quads); + } + + private void bakeElement(JsonObject element, ResolvedModel model, Quaternionf rotation, List out) { + float[] from = readVector(element.getAsJsonArray("from")); + float[] to = readVector(element.getAsJsonArray("to")); + Matrix4f transform = element.has("rotation") ? elementTransform(element.getAsJsonObject("rotation")) : null; + boolean shade = !element.has("shade") || element.get("shade").getAsBoolean(); + + for (Map.Entry entry : element.getAsJsonObject("faces").entrySet()) { + Direction direction = Direction.byName(entry.getKey()); + if (direction != null) { + out.add(this.bakeFace(direction, entry.getValue().getAsJsonObject(), from, to, transform, rotation, model, shade)); + } + } + } + + private BakedQuad bakeFace(Direction direction, JsonObject face, float[] from, float[] to, Matrix4f transform, + Quaternionf rotation, ResolvedModel model, boolean shade) { + float[] positions = new float[12]; + FaceGeometry.fill(positions, direction, from[0] / 16f, from[1] / 16f, from[2] / 16f, to[0] / 16f, to[1] / 16f, to[2] / 16f); + + Vector3f vertex = new Vector3f(); + for (int i = 0; i < 4; i++) { + vertex.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]); + if (transform != null) { + transform.transformPosition(vertex); + } + if (rotation != null) { + vertex.sub(0.5f, 0.5f, 0.5f); + rotation.transform(vertex); + vertex.add(0.5f, 0.5f, 0.5f); + } + positions[i * 3] = vertex.x; + positions[i * 3 + 1] = vertex.y; + positions[i * 3 + 2] = vertex.z; + } + + Vector3f normal = new Vector3f(direction.getStepX(), direction.getStepY(), direction.getStepZ()); + if (transform != null) { + transform.transformDirection(normal).normalize(); + } + if (rotation != null) { + rotation.transform(normal); + } + Direction lightFace = Direction.getApproximateNearest(normal.x, normal.y, normal.z); + + Direction cullFace = null; + if (face.has("cullface")) { + Direction cull = Direction.byName(face.get("cullface").getAsString()); + if (cull != null) { + Vector3f cullVector = new Vector3f(cull.getStepX(), cull.getStepY(), cull.getStepZ()); + if (rotation != null) { + rotation.transform(cullVector); + } + cullFace = Direction.getApproximateNearest(cullVector.x, cullVector.y, cullVector.z); + } + } + + String reference = face.has("texture") ? model.resolveTexture(face.get("texture").getAsString()) : null; + TextureData texture = this.textures.get(reference != null ? reference : MISSING_TEXTURE); + int tintIndex = face.has("tintindex") ? face.get("tintindex").getAsInt() : -1; + + return new BakedQuad(positions, bakeUvs(direction, face, from, to), texture.id(), lightFace, cullFace, tintIndex, shade, texture.translucent()); + } + + private static float[] bakeUvs(Direction direction, JsonObject face, float[] from, float[] to) { + float[] uv = face.has("uv") ? readVector(face.getAsJsonArray("uv")) : defaultUv(direction, from, to); + float[] corners = {uv[0], uv[1], uv[0], uv[3], uv[2], uv[3], uv[2], uv[1]}; + int rotation = face.has("rotation") ? face.get("rotation").getAsInt() : 0; + int[] order = FaceGeometry.UV_ROTATION[Math.floorMod(rotation / 90, 4)]; + float[] uvs = new float[8]; + for (int i = 0; i < 4; i++) { + uvs[i * 2] = corners[order[i] * 2] / 16f; + uvs[i * 2 + 1] = corners[order[i] * 2 + 1] / 16f; + } + return uvs; + } + + private static float[] defaultUv(Direction direction, float[] from, float[] to) { + return switch (direction) { + case DOWN -> new float[]{from[0], 16 - to[2], to[0], 16 - from[2]}; + case UP -> new float[]{from[0], from[2], to[0], to[2]}; + case NORTH -> new float[]{16 - to[0], 16 - to[1], 16 - from[0], 16 - from[1]}; + case SOUTH -> new float[]{from[0], 16 - to[1], to[0], 16 - from[1]}; + case WEST -> new float[]{from[2], 16 - to[1], to[2], 16 - from[1]}; + case EAST -> new float[]{16 - to[2], 16 - to[1], 16 - from[2], 16 - from[1]}; + }; + } + + private static Matrix4f elementTransform(JsonObject rotation) { + float angle = (float) Math.toRadians(rotation.get("angle").getAsFloat()); + float[] origin = readVector(rotation.getAsJsonArray("origin")); + String axis = rotation.get("axis").getAsString(); + float ox = origin[0] / 16f; + float oy = origin[1] / 16f; + float oz = origin[2] / 16f; + + Matrix4f matrix = new Matrix4f().translate(ox, oy, oz); + switch (axis) { + case "x" -> matrix.rotateX(angle); + case "y" -> matrix.rotateY(angle); + default -> matrix.rotateZ(angle); + } + if (rotation.has("rescale") && rotation.get("rescale").getAsBoolean()) { + float scale = 1f / (float) Math.cos(angle); + matrix.scale(axis.equals("x") ? 1f : scale, axis.equals("y") ? 1f : scale, axis.equals("z") ? 1f : scale); + } + return matrix.translate(-ox, -oy, -oz); + } + + private static float[] readVector(JsonArray array) { + float[] values = new float[array.size()]; + for (int i = 0; i < values.length; i++) { + values[i] = array.get(i).getAsFloat(); + } + return values; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/model/BlockStateSelector.java b/src/main/java/dev/loat/msmp_vision/model/BlockStateSelector.java new file mode 100644 index 0000000..8835e67 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/model/BlockStateSelector.java @@ -0,0 +1,153 @@ +package dev.loat.msmp_vision.model; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.ResourceIds; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Evaluates blockstate definition files ({@code variants} and {@code multipart}) for a concrete {@link BlockState}. + */ +public final class BlockStateSelector { + + /** + * A model reference selected from a blockstate file. + * + * @param model Model identifier. + * @param x X rotation in degrees. + * @param y Y rotation in degrees. + * @param uvlock Whether UVs are locked (currently not applied by the baker). + */ + public record ModelVariant(String model, int x, int y, boolean uvlock) {} + + private final AssetManager assets; + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + /** + * Creates a selector. + * + * @param assets The asset source. + */ + public BlockStateSelector(AssetManager assets) { + this.assets = assets; + } + + /** + * Selects the models to render for a block state. + * + * @param state The block state. + * @return The selected model variants, empty if the block has no definition. + */ + public List select(BlockState state) { + String blockId = BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(); + JsonObject definition = this.definition(blockId); + if (definition == null) { + return List.of(); + } + if (definition.has("variants")) { + for (Map.Entry entry : definition.getAsJsonObject("variants").entrySet()) { + if (matchesKey(state, entry.getKey())) { + return List.of(parseVariant(entry.getValue())); + } + } + return List.of(); + } + List result = new ArrayList<>(); + if (definition.has("multipart")) { + for (JsonElement part : definition.getAsJsonArray("multipart")) { + JsonObject object = part.getAsJsonObject(); + if (!object.has("when") || testCondition(state, object.getAsJsonObject("when"))) { + result.add(parseVariant(object.get("apply"))); + } + } + } + return result; + } + + private JsonObject definition(String blockId) { + Optional cached = this.cache.get(blockId); + if (cached == null) { + cached = Optional.ofNullable(this.assets.readJson(ResourceIds.blockStatePath(blockId))); + this.cache.put(blockId, cached); + } + return cached.orElse(null); + } + + private static ModelVariant parseVariant(JsonElement element) { + JsonObject object = element.isJsonArray() ? element.getAsJsonArray().get(0).getAsJsonObject() : element.getAsJsonObject(); + return new ModelVariant( + object.get("model").getAsString(), + object.has("x") ? object.get("x").getAsInt() : 0, + object.has("y") ? object.get("y").getAsInt() : 0, + object.has("uvlock") && object.get("uvlock").getAsBoolean() + ); + } + + private static boolean matchesKey(BlockState state, String key) { + if (key.isEmpty() || key.equals("normal")) { + return true; + } + for (String part : key.split(",")) { + int separator = part.indexOf('='); + if (separator < 0 || !propertyMatches(state, part.substring(0, separator), part.substring(separator + 1))) { + return false; + } + } + return true; + } + + private static boolean testCondition(BlockState state, JsonObject when) { + if (when.has("OR")) { + for (JsonElement sub : when.getAsJsonArray("OR")) { + if (testCondition(state, sub.getAsJsonObject())) { + return true; + } + } + return false; + } + if (when.has("AND")) { + JsonArray all = when.getAsJsonArray("AND"); + for (JsonElement sub : all) { + if (!testCondition(state, sub.getAsJsonObject())) { + return false; + } + } + return true; + } + for (Map.Entry entry : when.entrySet()) { + if (!propertyMatches(state, entry.getKey(), entry.getValue().getAsString())) { + return false; + } + } + return true; + } + + private static boolean propertyMatches(BlockState state, String name, String expected) { + for (Property property : state.getProperties()) { + if (property.getName().equals(name)) { + String actual = valueName(state, property); + for (String candidate : expected.split("\\|")) { + if (candidate.equals(actual)) { + return true; + } + } + return false; + } + } + return false; + } + + private static > String valueName(BlockState state, Property property) { + return property.getName(state.getValue(property)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/model/FaceGeometry.java b/src/main/java/dev/loat/msmp_vision/model/FaceGeometry.java new file mode 100644 index 0000000..77d2a86 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/model/FaceGeometry.java @@ -0,0 +1,47 @@ +package dev.loat.msmp_vision.model; + +import net.minecraft.core.Direction; + +/** + * Vertex layout of the six cube faces, shared by the model baker and the fluid mesher. + * + *

Index order follows {@link Direction#ordinal()}: DOWN, UP, NORTH, SOUTH, WEST, EAST.

+ */ +public final class FaceGeometry { + + /** {@code [face][vertex][x, y, z]} where 0 selects the minimum and 1 the maximum of the box. */ + private static final int[][][] VERTICES = { + {{0, 0, 1}, {0, 0, 0}, {1, 0, 0}, {1, 0, 1}}, // DOWN + {{0, 1, 0}, {0, 1, 1}, {1, 1, 1}, {1, 1, 0}}, // UP + {{1, 1, 0}, {1, 0, 0}, {0, 0, 0}, {0, 1, 0}}, // NORTH + {{0, 1, 1}, {0, 0, 1}, {1, 0, 1}, {1, 1, 1}}, // SOUTH + {{0, 1, 0}, {0, 0, 0}, {0, 0, 1}, {0, 1, 1}}, // WEST + {{1, 1, 1}, {1, 0, 1}, {1, 0, 0}, {1, 1, 0}} // EAST + }; + + /** UV corner permutation per face rotation (0, 90, 180, 270 degrees). */ + public static final int[][] UV_ROTATION = {{0, 1, 2, 3}, {1, 2, 3, 0}, {2, 3, 0, 1}, {3, 0, 1, 2}}; + + private FaceGeometry() {} + + /** + * Writes the four vertices of a face of an axis-aligned box. + * + * @param out Destination with at least 12 entries. + * @param face The face. + * @param minX Box minimum X. + * @param minY Box minimum Y. + * @param minZ Box minimum Z. + * @param maxX Box maximum X. + * @param maxY Box maximum Y. + * @param maxZ Box maximum Z. + */ + public static void fill(float[] out, Direction face, float minX, float minY, float minZ, float maxX, float maxY, float maxZ) { + int[][] vertices = VERTICES[face.ordinal()]; + for (int v = 0; v < 4; v++) { + out[v * 3] = vertices[v][0] == 0 ? minX : maxX; + out[v * 3 + 1] = vertices[v][1] == 0 ? minY : maxY; + out[v * 3 + 2] = vertices[v][2] == 0 ? minZ : maxZ; + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/model/ModelResolver.java b/src/main/java/dev/loat/msmp_vision/model/ModelResolver.java new file mode 100644 index 0000000..e7b7d11 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/model/ModelResolver.java @@ -0,0 +1,108 @@ +package dev.loat.msmp_vision.model; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.ResourceIds; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Loads block model JSON files and flattens their {@code parent} chain into a single {@link ResolvedModel}. + */ +public final class ModelResolver { + + private static final int MAX_DEPTH = 16; + + /** + * A model with inherited textures and elements merged in. + * + * @param textures Texture variables ({@code name -> reference}). + * @param elements The element array of the nearest ancestor defining one, or {@code null}. + */ + public record ResolvedModel(Map textures, JsonArray elements) { + + /** + * Resolves a texture reference such as {@code #side} to a concrete texture identifier. + * + * @param reference A texture variable ({@code #name}) or a direct identifier. + * @return The identifier, or {@code null} if the variable is undefined. + */ + public String resolveTexture(String reference) { + String current = reference; + for (int i = 0; i < MAX_DEPTH && current != null && current.startsWith("#"); i++) { + current = this.textures.get(current.substring(1)); + } + return current == null || current.startsWith("#") ? null : current; + } + } + + private final AssetManager assets; + private final ConcurrentHashMap> rawCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap resolvedCache = new ConcurrentHashMap<>(); + + /** + * Creates a resolver. + * + * @param assets The asset source. + */ + public ModelResolver(AssetManager assets) { + this.assets = assets; + } + + /** + * Resolves a model. Missing models yield an empty model (no elements). + * + * @param modelId Model identifier, e.g. {@code minecraft:block/stone}. + * @return The resolved model, never {@code null}. + */ + public ResolvedModel resolve(String modelId) { + String key = ResourceIds.normalize(modelId); + ResolvedModel cached = this.resolvedCache.get(key); + if (cached != null) { + return cached; + } + ResolvedModel resolved = this.build(key); + this.resolvedCache.put(key, resolved); + return resolved; + } + + private ResolvedModel build(String modelId) { + Map textures = new HashMap<>(); + JsonArray elements = null; + String current = modelId; + for (int depth = 0; current != null && depth < MAX_DEPTH; depth++) { + JsonObject json = this.raw(current); + if (json == null) { + break; + } + if (json.has("textures")) { + for (Map.Entry entry : json.getAsJsonObject("textures").entrySet()) { + textures.putIfAbsent(entry.getKey(), textureName(entry.getValue())); + } + } + if (elements == null && json.has("elements")) { + elements = json.getAsJsonArray("elements"); + } + current = json.has("parent") ? ResourceIds.normalize(json.get("parent").getAsString()) : null; + } + return new ResolvedModel(textures, elements); + } + + private static String textureName(JsonElement value) { + return value.isJsonObject() ? value.getAsJsonObject().get("sprite").getAsString() : value.getAsString(); + } + + private JsonObject raw(String modelId) { + Optional cached = this.rawCache.get(modelId); + if (cached == null) { + cached = Optional.ofNullable(this.assets.readJson(ResourceIds.modelPath(modelId))); + this.rawCache.put(modelId, cached); + } + return cached.orElse(null); + } +} From 1f520a4a589bb0a9058bc21994152a87e1eec2fd Mon Sep 17 00:00:00 2001 From: Mqx <62719703+Mqxx@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:27:36 +0200 Subject: [PATCH 03/18] feat: v1 prototype --- .../java/dev/loat/msmp_vision/MSMPVision.java | 11 +- .../dev/loat/msmp_vision/VisionService.java | 141 ++++++++++++ .../loat/msmp_vision/encode/ImageEncoder.java | 34 +++ .../dev/loat/msmp_vision/mesh/QuadBuffer.java | 76 +++++++ .../msmp_vision/mesh/SectionGeometry.java | 9 + .../loat/msmp_vision/mesh/SectionMesher.java | 183 +++++++++++++++ .../loat/msmp_vision/mesh/TintResolver.java | 136 +++++++++++ .../msmp_vision/msmp/endpoints/Endpoints.java | 7 +- .../msmp/endpoints/screenshot/Screenshot.java | 6 +- .../java/dev/loat/msmp_vision/raster/Fog.java | 43 ++++ .../loat/msmp_vision/raster/Framebuffer.java | 72 ++++++ .../msmp_vision/raster/GeometryProjector.java | 147 ++++++++++++ .../msmp_vision/raster/TileRasterizer.java | 211 ++++++++++++++++++ .../msmp_vision/raster/TriangleBuffer.java | 88 ++++++++ .../dev/loat/msmp_vision/render/Camera.java | 100 +++++++++ .../msmp_vision/render/RenderExecutor.java | 68 ++++++ .../msmp_vision/render/RenderSettings.java | 93 ++++++++ .../msmp_vision/render/SceneRenderer.java | 114 ++++++++++ .../loat/msmp_vision/render/SkyPainter.java | 71 ++++++ .../world/EnvironmentSnapshot.java | 16 ++ .../msmp_vision/world/SectionSnapshot.java | 113 ++++++++++ .../msmp_vision/world/SnapshotCapture.java | 89 ++++++++ .../loat/msmp_vision/world/WorldSnapshot.java | 104 +++++++++ 23 files changed, 1924 insertions(+), 8 deletions(-) create mode 100644 src/main/java/dev/loat/msmp_vision/VisionService.java create mode 100644 src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java create mode 100644 src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java create mode 100644 src/main/java/dev/loat/msmp_vision/mesh/SectionGeometry.java create mode 100644 src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java create mode 100644 src/main/java/dev/loat/msmp_vision/mesh/TintResolver.java create mode 100644 src/main/java/dev/loat/msmp_vision/raster/Fog.java create mode 100644 src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java create mode 100644 src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java create mode 100644 src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java create mode 100644 src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java create mode 100644 src/main/java/dev/loat/msmp_vision/render/Camera.java create mode 100644 src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java create mode 100644 src/main/java/dev/loat/msmp_vision/render/RenderSettings.java create mode 100644 src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java create mode 100644 src/main/java/dev/loat/msmp_vision/render/SkyPainter.java create mode 100644 src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java create mode 100644 src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java create mode 100644 src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java create mode 100644 src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java diff --git a/src/main/java/dev/loat/msmp_vision/MSMPVision.java b/src/main/java/dev/loat/msmp_vision/MSMPVision.java index 91e1379..6eb9f66 100644 --- a/src/main/java/dev/loat/msmp_vision/MSMPVision.java +++ b/src/main/java/dev/loat/msmp_vision/MSMPVision.java @@ -18,7 +18,8 @@ public class MSMPVision implements ModInitializer { * The shared {@code vision} namespace used for all MSMP registrations. * Attached to the running server in {@code SERVER_STARTED} and detached in {@code SERVER_STOPPED}. */ - private static final MSMPNamespace NS = new MSMPNamespace("vision"); + private static final MSMPNamespace NAMESPACE = new MSMPNamespace("vision"); + private final VisionService service = new VisionService(); /** * Provides access to the {@link ManagementServer} @@ -38,15 +39,17 @@ public void onInitialize() { Config.register(); - Endpoints.register(NS, () -> msmp); + Endpoints.register(NAMESPACE, () -> msmp, this.service); ServerLifecycleEvents.SERVER_STARTED.register(server -> { - NS.attach(server); + NAMESPACE.attach(server); msmp = new MSMPServer(server); + this.service.start(); }); ServerLifecycleEvents.SERVER_STOPPED.register(server -> { - NS.detach(); + this.service.stop(); + NAMESPACE.detach(); msmp = null; }); diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java new file mode 100644 index 0000000..704ce97 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/VisionService.java @@ -0,0 +1,141 @@ +package dev.loat.msmp_vision; + +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.TextureStore; +import dev.loat.msmp_vision.config.Config; +import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile; +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.ScreenshotRequest; +import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotResponse; +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.SceneRenderer; +import dev.loat.msmp_vision.world.SnapshotCapture; +import dev.loat.msmp_vision.world.WorldSnapshot; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +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.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Handles screenshot requests: validates, captures the world on the server thread and renders on worker threads. + */ +public final class VisionService { + + private record Runtime( + RenderExecutor executor, + SceneRenderer renderer, + CompletableFuture ready + ) {} + + private volatile Runtime runtime; + + /** + * Starts asset preparation and the render pool. Call on server start. + */ + public void start() { + this.stop(); + MSMPVisionConfigFile config = Config.getConfig(); + 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); + SceneRenderer renderer = new SceneRenderer(assets, textures, executor); + + CompletableFuture ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets)); + ready.whenComplete((ignored, error) -> { + if (error != null) { + Logger.error("Failed to prepare client assets: " + error); + } else { + Logger.info("Client assets ready (" + executor.threads() + " render threads)."); + } + }); + this.runtime = new Runtime(executor, renderer, ready); + } + + /** Stops the render pool. Call on server stop. */ + public void stop() { + Runtime current = this.runtime; + this.runtime = null; + if (current != null) { + current.executor().shutdown(); + } + } + + /** + * Renders a screenshot. + * + * @param server The server. + * @param request The request. + * @return The response containing a Base64 PNG. + * @throws IllegalArgumentException for invalid parameters. + * @throws IllegalStateException if the renderer is not ready or rendering failed. + */ + public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) { + Runtime current = this.runtime; + if (current == null) { + throw new IllegalStateException("The renderer is not running."); + } + if (!current.ready().isDone()) { + throw new IllegalStateException("Client assets are still being prepared, try again shortly."); + } + if (current.ready().isCompletedExceptionally()) { + throw new IllegalStateException("Client assets could not be prepared, see the server log."); + } + + MSMPVisionConfigFile config = Config.getConfig(); + 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.requestTimeoutSeconds); + 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); + } + + private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, long timeoutSeconds) { + if (server.isSameThread()) { + return SnapshotCapture.capture(level, camera, settings); + } + try { + return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings), server) + .get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("The server thread did not respond in time."); + } catch (ExecutionException e) { + throw new IllegalStateException("World capture failed: " + e.getCause(), e.getCause()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while capturing the world."); + } + } + + private ServerLevel resolveLevel(MinecraftServer server, String dimension) { + if (dimension == null || dimension.isBlank()) { + return server.overworld(); + } + ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.parse(dimension)); + ServerLevel level = server.getLevel(key); + if (level == null) { + throw new IllegalArgumentException("Unknown dimension: " + dimension); + } + return level; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java new file mode 100644 index 0000000..59aacf2 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java @@ -0,0 +1,34 @@ +package dev.loat.msmp_vision.encode; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Base64; + +/** + * Encodes rendered images for transport. + */ +public final class ImageEncoder { + + private ImageEncoder() {} + + /** + * Encodes an image as Base64 PNG. + * + * @param image The image. + * @return Base64 string without prefix. + */ + public static String toBase64Png(BufferedImage image) { + 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()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java new file mode 100644 index 0000000..8990a0e --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java @@ -0,0 +1,76 @@ +package dev.loat.msmp_vision.mesh; + +import java.util.Arrays; + + +/** + * Growable struct-of-arrays storage for lit, tinted quads in camera-relative coordinates. + */ +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[] textures = new int[64]; + private int count; + + /** + * Appends a quad. + * + * @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 Vertex colour multiplier as {@code 0xRRGGBB}. + */ + public void add( + float[] pos, + float offsetX, + float offsetY, + float offsetZ, + float[] uv, + int texture, + int rgb + ) { + 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.textures = Arrays.copyOf(this.textures, capacity); + } + int p = this.count * 12; + 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; + } + System.arraycopy(uv, 0, this.uvs, this.count * 8, 8); + this.colors[this.count] = rgb; + this.textures[this.count] = texture; + this.count++; + } + + /** @return Number of quads. */ + public int count() { return this.count; } + + /** @return Vertex positions, 12 floats per quad. */ + public float[] positions() { return this.positions; } + + /** @return Texture coordinates, 8 floats per quad. */ + public float[] uvs() { return this.uvs; } + + /** + * @param quad Quad index. + * @return Colour multiplier as {@code 0xRRGGBB}. + */ + public int color(int quad) { return this.colors[quad]; } + + /** + * @param quad Quad index. + * @return Texture id. + */ + public int texture(int quad) { return this.textures[quad]; } +} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/SectionGeometry.java b/src/main/java/dev/loat/msmp_vision/mesh/SectionGeometry.java new file mode 100644 index 0000000..2b05032 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/SectionGeometry.java @@ -0,0 +1,9 @@ +package dev.loat.msmp_vision.mesh; + +/** + * Meshing result of one section, split by render layer. + * + * @param opaque Quads rendered with depth write and no blending. + * @param translucent Quads rendered back-to-front with alpha blending. + */ +public record SectionGeometry(QuadBuffer opaque, QuadBuffer translucent) {} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java new file mode 100644 index 0000000..c4a3378 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java @@ -0,0 +1,183 @@ +package dev.loat.msmp_vision.mesh; + +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.assets.TextureStore; +import dev.loat.msmp_vision.model.BakedQuad; +import dev.loat.msmp_vision.model.BlockModelBaker; +import dev.loat.msmp_vision.model.FaceGeometry; +import dev.loat.msmp_vision.render.Camera; +import dev.loat.msmp_vision.world.EnvironmentSnapshot; +import dev.loat.msmp_vision.world.SectionSnapshot; +import dev.loat.msmp_vision.world.WorldSnapshot; +import net.minecraft.core.Direction; +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. + * + *

Instances hold scratch buffers and must not be shared between threads.

+ */ +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; + private static final float[] FLUID_UV_TOP = {0, 0, 0, 1, 1, 1, 1, 0}; + + private final WorldSnapshot world; + private final EnvironmentSnapshot environment; + private final BlockModelBaker baker; + private final TextureStore textures; + private final TintResolver tint; + private final int originX; + private final int originY; + private final int originZ; + private final int blend; + private final float[] scratch = new float[12]; + private final float[] sideUv = new float[8]; + + /** + * Creates a mesher. + * + * @param world The captured world. + * @param baker Block model baker. + * @param textures Texture registry. + * @param tint Tint resolver. + * @param camera Camera providing the coordinate origin. + * @param biomeBlend Biome blend radius. + */ + public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore textures, TintResolver tint, Camera camera, int biomeBlend) { + this.world = world; + this.environment = world.environment(); + this.baker = baker; + this.textures = textures; + this.tint = tint; + this.originX = camera.originX(); + this.originY = camera.originY(); + this.originZ = camera.originZ(); + this.blend = biomeBlend; + } + + /** + * Meshes a section. + * + * @param section The section. + * @return The geometry, possibly empty. + */ + public SectionGeometry mesh(SectionSnapshot section) { + QuadBuffer opaque = new QuadBuffer(); + QuadBuffer translucent = new QuadBuffer(); + int baseX = section.sectionX() << 4; + int baseY = section.sectionY() << 4; + int baseZ = section.sectionZ() << 4; + + for (int ly = 0; ly < 16; ly++) { + for (int lz = 0; lz < 16; lz++) { + for (int lx = 0; lx < 16; lx++) { + BlockState state = section.block(lx, ly, lz); + if (state.isAir()) { + continue; + } + int x = baseX + lx; + int y = baseY + ly; + int z = baseZ + lz; + boolean solid = state.isSolidRender(); + if (solid && this.enclosed(x, y, z)) { + continue; + } + FluidState fluid = state.getFluidState(); + if (!fluid.isEmpty()) { + this.meshFluid(fluid, x, y, z, opaque, translucent); + } + this.meshBlock(state, solid, x, y, z, opaque, translucent); + } + } + } + return new SectionGeometry(opaque, translucent); + } + + private boolean enclosed(int x, int y, int z) { + for (Direction direction : Direction.values()) { + if (!this.world.blockState(x + direction.getStepX(), y + direction.getStepY(), z + direction.getStepZ()).isSolidRender()) { + return false; + } + } + return true; + } + + private void meshBlock(BlockState state, boolean solid, int x, int y, int z, QuadBuffer opaque, QuadBuffer translucent) { + for (BakedQuad quad : this.baker.bake(state)) { + Direction cull = quad.cullFace(); + if (cull != null && this.world.blockState(x + cull.getStepX(), y + cull.getStepY(), z + cull.getStepZ()).isSolidRender()) { + continue; + } + Direction face = quad.face(); + float light = this.light(x, y, z, face, solid); + float shade = quad.shade() ? FACE_SHADE[face.ordinal()] : 1.0f; + int tintRgb = quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF; + int color = pack(shade * light, tintRgb); + (quad.translucent() ? translucent : opaque).add(quad.positions(), x - this.originX, y - this.originY, z - this.originZ, quad.uvs(), quad.texture(), color); + } + } + + private void meshFluid(FluidState fluid, int x, int y, int z, QuadBuffer opaque, QuadBuffer translucent) { + boolean water = fluid.is(FluidTags.WATER); + 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; + float light = water ? this.light(x, y, z, Direction.UP, false) : 1.0f; + + FluidState above = this.world.blockState(x, y + 1, z).getFluidState(); + boolean covered = !above.isEmpty() && above.getType().isSame(fluid.getType()); + float height = covered ? 1.0f : fluid.getOwnHeight(); + float rx = x - this.originX; + float ry = y - this.originY; + float rz = z - this.originZ; + + if (!covered) { + FaceGeometry.fill(this.scratch, Direction.UP, 0, 0, 0, 1, height, 1); + target.add(this.scratch, rx, ry, rz, FLUID_UV_TOP, still.id(), pack(light * FACE_SHADE[Direction.UP.ordinal()], tintRgb)); + } + for (Direction side : Direction.Plane.HORIZONTAL) { + BlockState neighbour = this.world.blockState(x + side.getStepX(), y, z + side.getStepZ()); + FluidState neighbourFluid = neighbour.getFluidState(); + if ((!neighbourFluid.isEmpty() && neighbourFluid.getType().isSame(fluid.getType())) || neighbour.isSolidRender()) { + continue; + } + float v0 = (1.0f - height) * 0.5f; + this.sideUv[0] = 0; this.sideUv[1] = v0; + this.sideUv[2] = 0; this.sideUv[3] = 0.5f; + this.sideUv[4] = 0.5f; this.sideUv[5] = 0.5f; + this.sideUv[6] = 0.5f; this.sideUv[7] = v0; + FaceGeometry.fill(this.scratch, side, 0, 0, 0, 1, height, 1); + float sideLight = water ? this.light(x + side.getStepX(), y, z + side.getStepZ(), side, false) : 1.0f; + target.add(this.scratch, rx, ry, rz, this.sideUv, flow.id(), pack(sideLight * FACE_SHADE[side.ordinal()], tintRgb)); + } + } + + /** Light of a face: sampled at the neighbour for solid blocks, at the block itself otherwise. */ + private float light(int x, int y, int z, Direction face, boolean solid) { + int lx = solid ? x + face.getStepX() : x; + int ly = solid ? y + face.getStepY() : y; + int lz = solid ? z + face.getStepZ() : z; + float sky = this.world.skyLight(lx, ly, lz) / 15.0f * this.environment.skyLightFactor(); + float block = this.world.blockLight(lx, ly, lz) / 15.0f; + float level = Math.max(sky, block); + return Math.max(level / (4.0f - 3.0f * level), MIN_LIGHT); + } + + private static int pack(float brightness, int tintRgb) { + int r = clamp(brightness * ((tintRgb >> 16) & 0xFF)); + int g = clamp(brightness * ((tintRgb >> 8) & 0xFF)); + int b = clamp(brightness * (tintRgb & 0xFF)); + return (r << 16) | (g << 8) | b; + } + + private static int clamp(float value) { + return Math.max(0, Math.min(255, (int) value)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/TintResolver.java b/src/main/java/dev/loat/msmp_vision/mesh/TintResolver.java new file mode 100644 index 0000000..7aa1d75 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/TintResolver.java @@ -0,0 +1,136 @@ +package dev.loat.msmp_vision.mesh; + +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.ResourceIds; +import dev.loat.msmp_vision.world.WorldSnapshot; +import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; +import net.minecraft.core.Holder; +import net.minecraft.world.level.DryFoliageColor; +import net.minecraft.world.level.FoliageColor; +import net.minecraft.world.level.GrassColor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; + +/** + * Computes biome-dependent tint colours for grass, foliage and water. + */ +public final class TintResolver { + + private enum Kind { GRASS, FOLIAGE, SPRUCE, BIRCH } + + private static final Map KINDS = new Reference2ObjectOpenHashMap<>(); + + static { + for (Block block : new Block[]{Blocks.GRASS_BLOCK, Blocks.BUSH, Blocks.FERN, Blocks.LARGE_FERN, Blocks.SHORT_GRASS, + Blocks.TALL_GRASS, Blocks.POTTED_FERN, Blocks.PINK_PETALS}) { + KINDS.put(block, Kind.GRASS); + } + for (Block block : new Block[]{Blocks.OAK_LEAVES, Blocks.JUNGLE_LEAVES, Blocks.ACACIA_LEAVES, Blocks.DARK_OAK_LEAVES, + Blocks.MANGROVE_LEAVES, Blocks.VINE, Blocks.SUGAR_CANE}) { + KINDS.put(block, Kind.FOLIAGE); + } + KINDS.put(Blocks.SPRUCE_LEAVES, Kind.SPRUCE); + KINDS.put(Blocks.BIRCH_LEAVES, Kind.BIRCH); + } + + /** + * Loads the vanilla colour maps into the (static) vanilla colour lookup tables. + * + * @param assets The asset source. + * @throws UncheckedIOException if a colour map cannot be read. + */ + public static void initializeColorMaps(AssetManager assets) { + GrassColor.init(read(assets, "minecraft:colormap/grass")); + FoliageColor.init(read(assets, "minecraft:colormap/foliage")); + DryFoliageColor.init(read(assets, "minecraft:colormap/dry_foliage")); + } + + private static int[] read(AssetManager assets, String id) { + byte[] bytes = assets.read(ResourceIds.texturePath(id)); + if (bytes == null) { + throw new UncheckedIOException(new IOException("Missing colour map " + id)); + } + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Resolves the tint of a block. + * + * @param world The world. + * @param state The block state. + * @param x World X. + * @param y World Y. + * @param z World Z. + * @param blend Blend radius. + * @return Tint as {@code 0xRRGGBB}; white if the block is not tinted. + */ + public int blockTint(WorldSnapshot world, BlockState state, int x, int y, int z, int blend) { + Kind kind = KINDS.get(state.getBlock()); + if (kind == null) { + return 0xFFFFFF; + } + return switch (kind) { + case SPRUCE -> FoliageColor.FOLIAGE_EVERGREEN & 0xFFFFFF; + case BIRCH -> FoliageColor.FOLIAGE_BIRCH & 0xFFFFFF; + case GRASS -> average(world, x, y, z, blend, (biome, bx, bz) -> biome.getGrassColor(bx, bz)); + case FOLIAGE -> average(world, x, y, z, blend, (biome, bx, bz) -> biome.getFoliageColor()); + }; + } + + /** + * Resolves the water tint. + * + * @param world The world. + * @param x World X. + * @param y World Y. + * @param z World Z. + * @param blend Blend radius. + * @return Tint as {@code 0xRRGGBB}. + */ + public int waterTint(WorldSnapshot world, int x, int y, int z, int blend) { + return average(world, x, y, z, blend, (biome, bx, bz) -> biome.getWaterColor()); + } + + @FunctionalInterface + private interface ColorFunction { + int apply(Biome biome, int x, int z); + } + + private static int average(WorldSnapshot world, int x, int y, int z, int radius, ColorFunction function) { + long r = 0; + long g = 0; + long b = 0; + int samples = 0; + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + Holder holder = world.biome(x + dx, y, z + dz); + if (holder == null) { + continue; + } + int color = function.apply(holder.value(), x + dx, z + dz); + r += (color >> 16) & 0xFF; + g += (color >> 8) & 0xFF; + b += color & 0xFF; + samples++; + } + } + if (samples == 0) { + return 0xFFFFFF; + } + return ((int) (r / samples) << 16) | ((int) (g / samples) << 8) | (int) (b / samples); + } +} 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 c5b75ae..0f46fe1 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 @@ -2,6 +2,8 @@ import dev.loat.msmp.MSMPNamespace; import dev.loat.msmp.MSMPServer; +import dev.loat.msmp_vision.VisionService; +import dev.loat.msmp_vision.msmp.endpoints.screenshot.Screenshot; import java.util.function.Supplier; @@ -15,7 +17,8 @@ private Endpoints() {} * @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 */ - public static void register(MSMPNamespace namespace, Supplier msmpServer) { - + 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 73f7cee..a830969 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,6 +1,7 @@ package dev.loat.msmp_vision.msmp.endpoints.screenshot; import dev.loat.msmp.MSMPNamespace; +import dev.loat.msmp_vision.VisionService; public class Screenshot { @@ -13,11 +14,12 @@ private Screenshot() {} * @param namespace The MSMP namespace to register under. * @param service The service handling the requests. */ - public static void register(MSMPNamespace namespace) { + 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.") .requestSchema(ScreenshotRequest.SCHEMA) .responseSchema(ScreenshotResponse.SCHEMA) - .register((server, client, request) -> null); + .register((server, client, request) -> service.screenshot(server, request)); } } diff --git a/src/main/java/dev/loat/msmp_vision/raster/Fog.java b/src/main/java/dev/loat/msmp_vision/raster/Fog.java new file mode 100644 index 0000000..fbfc5f5 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/Fog.java @@ -0,0 +1,43 @@ +package dev.loat.msmp_vision.raster; + +/** + * Linear 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. + */ +public record Fog( + float start, + float end, + int r, + int g, + int b +) { + + /** + * Creates fog from a packed colour. + * + * @param start Start distance. + * @param end End distance. + * @param rgb Colour as {@code 0xRRGGBB}. + * @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); + } + + /** + * @param distance View-space depth. + * @return Visibility 1 (no fog) to 0 (full fog). + */ + public float factor(float distance) { + float range = this.end - this.start; + if (range <= 0) { + return distance < this.end ? 1f : 0f; + } + return Math.max(0f, Math.min(1f, (this.end - distance) / range)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java new file mode 100644 index 0000000..105f1a7 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java @@ -0,0 +1,72 @@ +package dev.loat.msmp_vision.raster; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; + +/** + * Colour and depth storage. Depth stores {@code 1/w}, so larger means closer and {@code 0} means empty. + */ +public final class Framebuffer { + + private final int width; + private final int height; + private final int[] color; + private final float[] depth; + + /** + * Creates a cleared framebuffer. + * + * @param width Width in pixels. + * @param height Height in pixels. + */ + public Framebuffer(int width, int height) { + this.width = width; + this.height = height; + this.color = new int[width * height]; + this.depth = new float[width * height]; + } + + /** @return Width. */ + public int width() { return this.width; } + + /** @return Height. */ + public int height() { return this.height; } + + /** @return Colour buffer ({@code 0xFFRRGGBB}). */ + public int[] color() { return this.color; } + + /** @return Depth buffer ({@code 1/w}). */ + public float[] depth() { return this.depth; } + + /** + * Downsamples with a box filter. + * + * @param factor Supersampling factor. + * @return The final image. + */ + public BufferedImage toImage(int factor) { + int outWidth = this.width / factor; + int outHeight = this.height / factor; + BufferedImage image = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_INT_RGB); + int[] target = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int samples = factor * factor; + for (int y = 0; y < outHeight; y++) { + for (int x = 0; x < outWidth; x++) { + int r = 0; + int g = 0; + int b = 0; + for (int sy = 0; sy < factor; sy++) { + int row = (y * factor + sy) * this.width + x * factor; + for (int sx = 0; sx < factor; sx++) { + int pixel = this.color[row + sx]; + r += (pixel >> 16) & 0xFF; + g += (pixel >> 8) & 0xFF; + b += pixel & 0xFF; + } + } + target[y * outWidth + x] = ((r / samples) << 16) | ((g / samples) << 8) | (b / samples); + } + } + return image; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java new file mode 100644 index 0000000..c09c84a --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java @@ -0,0 +1,147 @@ +package dev.loat.msmp_vision.raster; + +import dev.loat.msmp_vision.mesh.QuadBuffer; + +/** + * Transforms quads into clipped, back-face culled screen-space triangles. + * + *

Instances hold scratch buffers and must not be shared between threads.

+ */ +public final class GeometryProjector { + + private static final float NEAR_W = 0.05f; + private static final int V = TriangleBuffer.VERTEX; + + private final float[] m; + private final int width; + private final int height; + private final boolean cull; + private final float[][] quad = new float[4][V]; + private final float[][] input = new float[3][]; + private final float[][] clipped = new float[5][V]; + private final float[][] screen = new float[5][V]; + + /** + * Creates a projector. + * + * @param viewProjection Column-major view-projection matrix. + * @param width Framebuffer width. + * @param height Framebuffer height. + * @param backfaceCulling Whether back faces are dropped. + */ + public GeometryProjector(float[] viewProjection, int width, int height, boolean backfaceCulling) { + this.m = viewProjection; + this.width = width; + this.height = height; + this.cull = backfaceCulling; + } + + /** + * Projects all quads of a buffer. + * + * @param quads The quads. + * @return Screen-space triangles. + */ + public TriangleBuffer project(QuadBuffer quads) { + TriangleBuffer out = new TriangleBuffer(); + for (int q = 0; q < quads.count(); q++) { + if (!this.load(quads, q)) { + continue; + } + int texture = quads.texture(q); + this.emit(out, texture, this.quad[0], this.quad[1], this.quad[2]); + this.emit(out, texture, this.quad[0], this.quad[2], this.quad[3]); + } + return out; + } + + /** Loads a quad into clip space; returns {@code false} if it is trivially invisible. */ + 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++) { + int i = q * 12 + v * 3; + float px = pos[i], py = pos[i + 1], pz = pos[i + 2]; + float x = this.m[0] * px + this.m[4] * py + this.m[8] * pz + this.m[12]; + float y = this.m[1] * px + this.m[5] * py + this.m[9] * pz + this.m[13]; + float w = this.m[3] * px + this.m[7] * py + this.m[11] * pz + this.m[15]; + float[] c = this.quad[v]; + c[0] = x; + c[1] = y; + 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; + if (x >= -w) left = false; + if (x <= w) right = false; + if (y >= -w) below = false; + if (y <= w) above = false; + if (w >= NEAR_W) behind = false; + } + return !(left || right || below || above || behind); + } + + private void emit(TriangleBuffer out, int texture, float[] a, float[] b, float[] c) { + this.input[0] = a; + this.input[1] = b; + this.input[2] = c; + int n = this.clipNear(); + if (n < 3) { + return; + } + for (int i = 0; i < n; i++) { + float[] s = this.clipped[i]; + float[] d = this.screen[i]; + float invW = 1f / s[2]; + d[0] = (s[0] * invW * 0.5f + 0.5f) * this.width; + d[1] = (0.5f - s[1] * invW * 0.5f) * this.height; + 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]; + } + for (int i = 1; i < n - 1; i++) { + float[] p = this.screen[0]; + float[] q = this.screen[i]; + float[] r = this.screen[i + 1]; + double area = (double) (q[0] - p[0]) * (r[1] - p[1]) - (double) (q[1] - p[1]) * (r[0] - p[0]); + if (area == 0 || (this.cull && area > 0)) { + continue; + } + float depth = (1f / p[2] + 1f / q[2] + 1f / r[2]) / 3f; + out.add(texture, p, q, r, depth); + } + } + + /** Sutherland-Hodgman clip of {@link #input} against {@code w >= NEAR_W} into {@link #clipped}. */ + private int clipNear() { + int n = 0; + for (int i = 0; i < 3; i++) { + float[] current = this.input[i]; + float[] next = this.input[(i + 1) % 3]; + boolean currentIn = current[2] >= NEAR_W; + boolean nextIn = next[2] >= NEAR_W; + if (currentIn) { + System.arraycopy(current, 0, this.clipped[n++], 0, V); + } + if (currentIn != nextIn) { + float t = (NEAR_W - current[2]) / (next[2] - current[2]); + float[] dest = this.clipped[n++]; + for (int k = 0; k < V; k++) { + dest[k] = current[k] + (next[k] - current[k]) * t; + } + } + } + return n; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java new file mode 100644 index 0000000..6a20ae8 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java @@ -0,0 +1,211 @@ +package dev.loat.msmp_vision.raster; + +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.render.RenderExecutor; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Multi-threaded tile based triangle rasterizer. Triangles are binned per tile, each tile is an independent job + * (opaque pass first, then translucent triangles back-to-front), so no synchronization is needed on the framebuffer. + */ +public final class TileRasterizer { + + private static final int T = TriangleBuffer.TRIANGLE; + private static final int V = TriangleBuffer.VERTEX; + + private final RenderExecutor executor; + private final int tileSize; + private final long timeoutSeconds; + + /** + * Creates a rasterizer. + * + * @param executor Worker pool. + * @param tileSize Tile edge length in pixels. + * @param timeoutSeconds Maximum time for the whole raster pass. + */ + public TileRasterizer(RenderExecutor executor, int tileSize, long timeoutSeconds) { + this.executor = executor; + this.tileSize = Math.max(8, tileSize); + this.timeoutSeconds = timeoutSeconds; + } + + /** + * Rasterizes both layers into the framebuffer. + * + * @param opaque Opaque triangles (any order). + * @param translucent Translucent triangles (sorted internally). + * @param target The framebuffer, may already contain a sky. + * @param textures Textures indexed by texture id. + * @param fog Distance fog. + */ + public void rasterize(TriangleBuffer opaque, TriangleBuffer translucent, Framebuffer target, TextureData[] textures, Fog fog) { + int tilesX = (target.width() + this.tileSize - 1) / this.tileSize; + int tilesY = (target.height() + this.tileSize - 1) / this.tileSize; + + IntArrayList[] opaqueBins = this.bin(opaque, null, tilesX, tilesY); + IntArrayList[] translucentBins = this.bin(translucent, this.sortBackToFront(translucent), tilesX, tilesY); + + List> jobs = new ArrayList<>(tilesX * tilesY); + for (int ty = 0; ty < tilesY; ty++) { + for (int tx = 0; tx < tilesX; tx++) { + int bin = ty * tilesX + tx; + if (opaqueBins[bin] == null && translucentBins[bin] == null) { + continue; + } + int x0 = tx * this.tileSize; + int y0 = ty * this.tileSize; + int x1 = Math.min(target.width(), x0 + this.tileSize); + int y1 = Math.min(target.height(), y0 + this.tileSize); + jobs.add(CompletableFuture.runAsync(() -> { + this.drawBin(opaque, opaqueBins[bin], false, x0, y0, x1, y1, target, textures, fog); + this.drawBin(translucent, translucentBins[bin], true, x0, y0, x1, y1, target, textures, fog); + }, this.executor.executor())); + } + } + this.executor.awaitAll(jobs, this.timeoutSeconds); + } + + private int[] sortBackToFront(TriangleBuffer buffer) { + long[] keys = new long[buffer.count()]; + for (int i = 0; i < keys.length; i++) { + keys[i] = ((long) Float.floatToIntBits(buffer.depth(i)) << 32) | i; + } + Arrays.sort(keys); + int[] order = new int[keys.length]; + for (int i = 0; i < keys.length; i++) { + order[i] = (int) keys[keys.length - 1 - i]; + } + return order; + } + + private IntArrayList[] bin(TriangleBuffer buffer, int[] order, int tilesX, int tilesY) { + IntArrayList[] bins = new IntArrayList[tilesX * tilesY]; + float[] d = buffer.data(); + for (int n = 0; n < buffer.count(); n++) { + int i = order == null ? n : order[n]; + int o = i * T; + float minX = Math.min(d[o], Math.min(d[o + V], d[o + 2 * V])); + float maxX = Math.max(d[o], Math.max(d[o + V], d[o + 2 * V])); + float minY = Math.min(d[o + 1], Math.min(d[o + V + 1], d[o + 2 * V + 1])); + float maxY = Math.max(d[o + 1], Math.max(d[o + V + 1], d[o + 2 * V + 1])); + int tx0 = Math.max(0, (int) Math.floor(minX) / this.tileSize); + int tx1 = Math.min(tilesX - 1, (int) Math.floor(maxX) / this.tileSize); + int ty0 = Math.max(0, (int) Math.floor(minY) / this.tileSize); + int ty1 = Math.min(tilesY - 1, (int) Math.floor(maxY) / this.tileSize); + if (maxX < 0 || maxY < 0) { + continue; + } + for (int ty = ty0; ty <= ty1; ty++) { + for (int tx = tx0; tx <= tx1; tx++) { + int bin = ty * tilesX + tx; + if (bins[bin] == null) { + bins[bin] = new IntArrayList(); + } + bins[bin].add(i); + } + } + } + return bins; + } + + private void drawBin(TriangleBuffer buffer, IntArrayList bin, boolean translucentPass, int x0, int y0, int x1, int y1, + Framebuffer target, TextureData[] textures, Fog fog) { + if (bin == null) { + return; + } + for (int n = 0; n < bin.size(); n++) { + int i = bin.getInt(n); + TextureData texture = textures[buffer.texture(i)]; + if (texture != null) { + this.drawTriangle(buffer.data(), i * T, texture, translucentPass, x0, y0, x1, y1, target, fog); + } + } + } + + private void drawTriangle(float[] d, int o, TextureData texture, boolean translucentPass, int x0, int y0, int x1, int y1, + Framebuffer target, Fog fog) { + float ax = d[o], ay = d[o + 1], aw = d[o + 2], au = d[o + 3], av = d[o + 4], ar = d[o + 5], ag = d[o + 6], ab = d[o + 7]; + float bx = d[o + V], by = d[o + V + 1], bw = d[o + V + 2], bu = d[o + V + 3], bv = d[o + V + 4], br = d[o + V + 5], bg = d[o + V + 6], bb = d[o + V + 7]; + float cx = d[o + 2 * V], cy = d[o + 2 * V + 1], cw = d[o + 2 * V + 2], cu = d[o + 2 * V + 3], cv = d[o + 2 * V + 4], cr = d[o + 2 * V + 5], cg = d[o + 2 * V + 6], cb = d[o + 2 * V + 7]; + + double area = (double) (bx - ax) * (cy - ay) - (double) (by - ay) * (cx - ax); + if (area == 0) { + return; + } + double inv = 1.0 / area; + int minX = Math.max(x0, (int) Math.floor(Math.min(ax, Math.min(bx, cx)))); + int maxX = Math.min(x1 - 1, (int) Math.ceil(Math.max(ax, Math.max(bx, cx)))); + int minY = Math.max(y0, (int) Math.floor(Math.min(ay, Math.min(by, cy)))); + int maxY = Math.min(y1 - 1, (int) Math.ceil(Math.max(ay, Math.max(by, cy)))); + if (minX > maxX || minY > maxY) { + return; + } + + double px = minX + 0.5; + double py = minY + 0.5; + double e0Row = (cx - bx) * (py - by) - (cy - by) * (px - bx); + double e1Row = (ax - cx) * (py - cy) - (ay - cy) * (px - cx); + double e2Row = (bx - ax) * (py - ay) - (by - ay) * (px - ax); + double e0dx = -(cy - by), e0dy = cx - bx; + double e1dx = -(ay - cy), e1dy = ax - cx; + double e2dx = -(by - ay), e2dy = bx - ax; + + int[] color = target.color(); + float[] depth = target.depth(); + int width = target.width(); + + for (int y = minY; y <= maxY; y++) { + double e0 = e0Row, e1 = e1Row, e2 = e2Row; + for (int x = minX; x <= maxX; x++) { + double w0 = e0 * inv, w1 = e1 * inv, w2 = e2 * inv; + e0 += e0dx; + e1 += e1dx; + e2 += e2dx; + if (w0 < 0 || w1 < 0 || w2 < 0) { + continue; + } + int idx = y * width + x; + float invW = (float) (w0 * aw + w1 * bw + w2 * cw); + if (invW <= depth[idx]) { + continue; + } + float w = 1f / invW; + float u = (float) (w0 * au + w1 * bu + w2 * cu) * w; + float v = (float) (w0 * av + w1 * bv + w2 * cv) * w; + int texel = texture.sample(u, v); + int alpha = texel >>> 24; + if (alpha == 0) { + continue; + } + float lr = (float) (w0 * ar + w1 * br + w2 * cr); + float lg = (float) (w0 * ag + w1 * bg + w2 * cg); + float lb = (float) (w0 * ab + w1 * bb + w2 * cb); + float f = fog.factor(w); + 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))); + + if (!translucentPass || alpha == 255) { + color[idx] = 0xFF000000 | (r << 16) | (g << 8) | b; + depth[idx] = invW; + } else { + int dst = color[idx]; + int inverse = 255 - alpha; + int or = (r * alpha + ((dst >> 16) & 0xFF) * inverse) / 255; + int og = (g * alpha + ((dst >> 8) & 0xFF) * inverse) / 255; + int ob = (b * alpha + (dst & 0xFF) * inverse) / 255; + color[idx] = 0xFF000000 | (or << 16) | (og << 8) | ob; + } + } + e0Row += e0dy; + e1Row += e1dy; + e2Row += e2dy; + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java b/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java new file mode 100644 index 0000000..f161546 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java @@ -0,0 +1,88 @@ +package dev.loat.msmp_vision.raster; + +import java.util.Arrays; +import java.util.List; + + +/** + * Screen-space triangles. Per vertex 8 floats: {@code x, y, 1/w, u/w, v/w, r, g, b}. + */ +public final class TriangleBuffer { + + /** Floats per vertex. */ + public static final int VERTEX = 8; + /** Floats per triangle. */ + public static final int TRIANGLE = VERTEX * 3; + + private float[] data = new float[TRIANGLE * 64]; + private int[] textures = new int[64]; + private float[] depths = new float[64]; + private int count; + + /** + * Appends a triangle. + * + * @param texture Texture id. + * @param a First vertex. + * @param b Second vertex. + * @param c Third vertex. + * @param depth Average view depth, used for sorting. + */ + public void add(int texture, float[] a, float[] b, float[] c, float depth) { + if (this.count == this.textures.length) { + int capacity = this.count * 2; + this.data = Arrays.copyOf(this.data, capacity * TRIANGLE); + this.textures = Arrays.copyOf(this.textures, capacity); + this.depths = Arrays.copyOf(this.depths, capacity); + } + int o = this.count * TRIANGLE; + System.arraycopy(a, 0, this.data, o, VERTEX); + System.arraycopy(b, 0, this.data, o + VERTEX, VERTEX); + System.arraycopy(c, 0, this.data, o + 2 * VERTEX, VERTEX); + this.textures[this.count] = texture; + this.depths[this.count] = depth; + this.count++; + } + + /** @return Triangle count. */ + public int count() { return this.count; } + + /** @return Vertex data. */ + public float[] data() { return this.data; } + + /** + * @param triangle Triangle index. + * @return Texture id. + */ + public int texture(int triangle) { return this.textures[triangle]; } + + /** + * @param triangle Triangle index. + * @return Average view depth. + */ + public float depth(int triangle) { return this.depths[triangle]; } + + /** + * Concatenates buffers. + * + * @param buffers The buffers. + * @return A new buffer containing all triangles. + */ + public static TriangleBuffer merge(List buffers) { + int total = 0; + for (TriangleBuffer buffer : buffers) { + total += buffer.count; + } + TriangleBuffer merged = new TriangleBuffer(); + merged.data = new float[Math.max(1, total) * TRIANGLE]; + merged.textures = new int[Math.max(1, total)]; + merged.depths = new float[Math.max(1, total)]; + for (TriangleBuffer buffer : buffers) { + System.arraycopy(buffer.data, 0, merged.data, merged.count * TRIANGLE, buffer.count * TRIANGLE); + System.arraycopy(buffer.textures, 0, merged.textures, merged.count, buffer.count); + System.arraycopy(buffer.depths, 0, merged.depths, merged.count, buffer.count); + merged.count += buffer.count; + } + return merged; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/Camera.java b/src/main/java/dev/loat/msmp_vision/render/Camera.java new file mode 100644 index 0000000..f5c6518 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/Camera.java @@ -0,0 +1,100 @@ +package dev.loat.msmp_vision.render; + +import org.joml.FrustumIntersection; +import org.joml.Matrix4f; +import org.joml.Vector3f; + +/** + * Perspective camera. All geometry is expressed relative to an integer origin near the camera so that + * 32-bit floats stay precise far from the world origin. + */ +public final class Camera { + + private final int originX; + private final int originY; + private final int originZ; + private final Vector3f forward; + private final Vector3f right; + private final Vector3f up; + private final float tanHalfFov; + private final float aspect; + private final float[] viewProjection = new float[16]; + private final FrustumIntersection frustum; + + /** + * Creates a camera from render settings. + * + * @param settings The settings. + */ + public Camera(RenderSettings settings) { + this.originX = (int) Math.floor(settings.position().get(0)); + this.originY = (int) Math.floor(settings.position().get(1)); + this.originZ = (int) Math.floor(settings.position().get(2)); + + double yaw = Math.toRadians(settings.yaw()); + double pitch = Math.toRadians(settings.pitch()); + this.forward = new Vector3f( + (float) (-Math.sin(yaw) * Math.cos(pitch)), + (float) -Math.sin(pitch), + (float) (Math.cos(yaw) * Math.cos(pitch)) + ); + Vector3f worldUp = new Vector3f(0, 1, 0); + this.right = new Vector3f(this.forward).cross(worldUp).normalize(); + this.up = new Vector3f(this.right).cross(this.forward).normalize(); + this.aspect = (float) settings.width() / settings.height(); + this.tanHalfFov = (float) Math.tan(Math.toRadians(settings.fov()) / 2.0); + + Vector3f eye = new Vector3f( + (float) (settings.position().get(0) - this.originX), + (float) (settings.position().get(1) - this.originY), + (float) (settings.position().get(2) - this.originZ) + ); + Matrix4f matrix = new Matrix4f() + .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistance() + 64f) + .lookAt(eye, new Vector3f(eye).add(this.forward), new Vector3f(0, 1, 0)); + matrix.get(this.viewProjection); + this.frustum = new FrustumIntersection(matrix); + } + + /** @return X of the coordinate origin all geometry is relative to. */ + public int originX() { return this.originX; } + + /** @return Y of the coordinate origin. */ + public int originY() { return this.originY; } + + /** @return Z of the coordinate origin. */ + public int originZ() { return this.originZ; } + + /** @return Normalized view direction. */ + public Vector3f forward() { return this.forward; } + + /** @return Normalized right vector. */ + public Vector3f right() { return this.right; } + + /** @return Normalized up vector. */ + public Vector3f up() { return this.up; } + + /** @return {@code tan(fov / 2)}. */ + public float tanHalfFov() { return this.tanHalfFov; } + + /** @return Width divided by height. */ + public float aspect() { return this.aspect; } + + /** @return Column-major view-projection matrix (16 floats). */ + public float[] viewProjection() { return this.viewProjection; } + + /** + * Tests whether a 16-block section may be visible. + * + * @param sectionX Section X. + * @param sectionY Section Y. + * @param sectionZ Section Z. + * @return {@code false} if the section is certainly outside the view frustum. + */ + public boolean intersectsSection(int sectionX, int sectionY, int sectionZ) { + float minX = (sectionX << 4) - this.originX; + float minY = (sectionY << 4) - this.originY; + float minZ = (sectionZ << 4) - this.originZ; + return this.frustum.testAab(minX, minY, minZ, minX + 16, minY + 16, minZ + 16); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java new file mode 100644 index 0000000..ade7cda --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java @@ -0,0 +1,68 @@ +package dev.loat.msmp_vision.render; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +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; + +/** + * Daemon thread pool used for meshing and rasterization. + */ +public final class RenderExecutor { + + private final ExecutorService pool; + private final int threads; + + /** + * Creates the pool. + * + * @param configuredThreads Thread count, {@code 0} selects the number of available processors. + */ + public RenderExecutor(int configuredThreads) { + this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors()); + AtomicInteger counter = new AtomicInteger(); + this.pool = Executors.newFixedThreadPool(this.threads, runnable -> { + Thread thread = new Thread(runnable, "msmp-vision-render-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } + + /** @return The underlying executor. */ + public ExecutorService executor() { return this.pool; } + + /** @return Number of worker threads. */ + public int threads() { return this.threads; } + + /** + * Waits for all jobs, cancelling the rest on timeout or failure. + * + * @param jobs The jobs. + * @param timeoutSeconds Maximum wait. + * @throws IllegalStateException on timeout, failure or interruption. + */ + public void awaitAll(List> jobs, long timeoutSeconds) { + try { + CompletableFuture.allOf(jobs.toArray(new CompletableFuture[0])).get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering timed out after " + timeoutSeconds + "s"); + } catch (ExecutionException e) { + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering failed: " + e.getCause(), e.getCause()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering interrupted"); + } + } + + /** Stops all worker threads. */ + public void shutdown() { + this.pool.shutdownNow(); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java new file mode 100644 index 0000000..99ce9d5 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java @@ -0,0 +1,93 @@ +package dev.loat.msmp_vision.render; + +import java.util.List; + +import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile; +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; + + +/** + * 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 yaw Yaw in degrees (Minecraft convention, 0 = south). + * @param pitch Pitch in degrees (positive = down). + * @param width Output width in pixels. + * @param height Output height in pixels. + * @param fov Vertical field of view in degrees. + * @param renderDistance Render distance in blocks. + * @param ssaa Supersampling factor (1 = off). + * @param biomeBlend Biome tint blend radius in blocks. + */ +public record RenderSettings( + List position, + double yaw, + double pitch, + int width, + int height, + double fov, + int renderDistance, + int ssaa, + int biomeBlend +) { + + /** + * Builds validated settings. + * + * @param request The request; zero 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. + */ + 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); + 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(), + width, + height, + fov, + distance, + ssaa, + Math.min(blend, 4) + ); + } + + /** @return Framebuffer width including supersampling. */ + public int renderWidth() { + return this.width * this.ssaa; + } + + /** @return Framebuffer height including supersampling. */ + public int renderHeight() { + return this.height * this.ssaa; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java new file mode 100644 index 0000000..aabe3e7 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java @@ -0,0 +1,114 @@ +package dev.loat.msmp_vision.render; + +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.assets.TextureStore; +import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile; +import dev.loat.msmp_vision.mesh.SectionGeometry; +import dev.loat.msmp_vision.mesh.SectionMesher; +import dev.loat.msmp_vision.mesh.TintResolver; +import dev.loat.msmp_vision.model.BlockModelBaker; +import dev.loat.msmp_vision.model.BlockStateSelector; +import dev.loat.msmp_vision.model.ModelResolver; +import dev.loat.msmp_vision.raster.Fog; +import dev.loat.msmp_vision.raster.Framebuffer; +import dev.loat.msmp_vision.raster.GeometryProjector; +import dev.loat.msmp_vision.raster.TileRasterizer; +import dev.loat.msmp_vision.raster.TriangleBuffer; +import dev.loat.msmp_vision.world.SectionSnapshot; +import dev.loat.msmp_vision.world.WorldSnapshot; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Orchestrates one render: mesh and project sections in parallel, paint the sky, rasterize tiles in parallel. + * Thread-safe; baked models and textures are cached across renders. + */ +public final class SceneRenderer { + + private record Projected(TriangleBuffer opaque, TriangleBuffer translucent) {} + + private final TextureStore textures; + private final RenderExecutor executor; + private final BlockModelBaker baker; + private final TintResolver tint = new TintResolver(); + + /** + * Creates a renderer. + * + * @param assets Asset source. + * @param textures Texture registry. + * @param executor Worker pool. + */ + public SceneRenderer(AssetManager assets, TextureStore textures, RenderExecutor executor) { + this.textures = textures; + this.executor = executor; + this.baker = new BlockModelBaker(new BlockStateSelector(assets), new ModelResolver(assets), textures); + } + + /** + * Renders a captured world. + * + * @param world The snapshot. + * @param camera The camera. + * @param settings The settings. + * @param config The current configuration. + * @return The final image. + */ + public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { + long timeout = config.requestTimeoutSeconds; + int width = settings.renderWidth(); + int height = settings.renderHeight(); + boolean cull = config.rendering.backfaceCulling; + + List> jobs = new ArrayList<>(); + for (SectionSnapshot section : world.sections()) { + if (section.isEmpty()) { + continue; + } + jobs.add(CompletableFuture.supplyAsync(() -> { + SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend()).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())); + } + this.executor.awaitAll(jobs, timeout); + + List opaque = new ArrayList<>(jobs.size()); + List translucent = new ArrayList<>(jobs.size()); + for (CompletableFuture job : jobs) { + Projected projected = job.join(); + if (projected.opaque().count() > 0) opaque.add(projected.opaque()); + if (projected.translucent().count() > 0) translucent.add(projected.translucent()); + } + + Framebuffer framebuffer = new Framebuffer(width, height); + 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) + .rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog); + return framebuffer.toImage(settings.ssaa()); + } + + private TextureData[] texturesById() { + TextureData[] all = this.textures.snapshot(); + int max = -1; + for (TextureData texture : all) { + if (texture != null) { + max = Math.max(max, texture.id()); + } + } + TextureData[] byId = new TextureData[max + 1]; + for (TextureData texture : all) { + if (texture != null) { + byId[texture.id()] = texture; + } + } + return byId; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java new file mode 100644 index 0000000..5db0284 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java @@ -0,0 +1,71 @@ +package dev.loat.msmp_vision.render; + +import dev.loat.msmp_vision.raster.Framebuffer; +import dev.loat.msmp_vision.world.EnvironmentSnapshot; +import org.joml.Vector3f; + +import java.util.Arrays; + +/** + * 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. + */ +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; + + private SkyPainter() {} + + /** + * Paints the sky and clears depth. + * + * @param target The framebuffer. + * @param camera The camera. + * @param environment Environment values. + */ + public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment) { + int width = target.width(); + int height = target.height(); + 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(); + + Vector3f dir = new Vector3f(); + for (int y = 0; y < height; y++) { + float ndcY = 1f - 2f * (y + 0.5f) / height; + for (int x = 0; x < width; x++) { + float ndcX = 2f * (x + 0.5f) / width - 1f; + dir.set(camera.forward()) + .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 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; + } + color[y * width + x] = 0xFF000000 | (clamp(r) << 16) | (clamp(g) << 8) | clamp(b); + } + } + } + + private static float[] rgb(int packed) { + return new float[]{(packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF}; + } + + private static int clamp(float v) { + return Math.max(0, Math.min(255, (int) v)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java new file mode 100644 index 0000000..1f11b27 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java @@ -0,0 +1,16 @@ +package dev.loat.msmp_vision.world; + +/** + * Time- and dimension-dependent values read from the level at capture time. + * + * @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 hasSkyLight Whether the dimension has sky light. + */ +public record EnvironmentSnapshot( + double sunAngleRadians, + float skyLightFactor, + int fogColor, + boolean hasSkyLight +) {} diff --git a/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java new file mode 100644 index 0000000..3723350 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java @@ -0,0 +1,113 @@ +package dev.loat.msmp_vision.world; + +import net.minecraft.core.Holder; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.DataLayer; + +/** + * Immutable copy of one 16x16x16 chunk section (blocks, biomes, light), safe to read from any thread. + */ +public final class SectionSnapshot { + + private final int sectionX; + private final int sectionY; + private final int sectionZ; + private final PalettedContainer states; + private final PalettedContainerRO> biomes; + private final DataLayer skyLight; + private final DataLayer blockLight; + + /** + * Creates a snapshot. + * + * @param sectionX Section X. + * @param sectionY Section Y. + * @param sectionZ Section Z. + * @param states Copied block container, or {@code null} for an all-air section. + * @param biomes Copied biome container. + * @param skyLight Copied sky light, or {@code null}. + * @param blockLight Copied block light, or {@code null}. + */ + public SectionSnapshot( + int sectionX, + int sectionY, + int sectionZ, + PalettedContainer states, + PalettedContainerRO> biomes, + DataLayer skyLight, + DataLayer blockLight + ) { + this.sectionX = sectionX; + this.sectionY = sectionY; + this.sectionZ = sectionZ; + this.states = states; + this.biomes = biomes; + this.skyLight = skyLight; + this.blockLight = blockLight; + } + + /** @return Section X. */ + public int sectionX() { return this.sectionX; } + + /** @return Section Y. */ + public int sectionY() { return this.sectionY; } + + /** @return Section Z. */ + public int sectionZ() { return this.sectionZ; } + + /** @return {@code true} if the section contains only air. */ + public boolean isEmpty() { return this.states == null; } + + /** + * Reads a block. + * + * @param x Local X (0-15). + * @param y Local Y (0-15). + * @param z Local Z (0-15). + * @return The block state. + */ + public BlockState block(int x, int y, int z) { + return this.states == null ? Blocks.AIR.defaultBlockState() : this.states.get(x, y, z); + } + + /** + * Reads a biome (4x4x4 resolution). + * + * @param x Local X (0-15). + * @param y Local Y (0-15). + * @param z Local Z (0-15). + * @return The biome holder. + */ + public Holder biome(int x, int y, int z) { + return this.biomes.get(x >> 2, y >> 2, z >> 2); + } + + /** + * Reads sky light. + * + * @param x Local X. + * @param y Local Y. + * @param z Local Z. + * @param fallback Value used when no light data exists. + * @return Light level 0-15. + */ + public int skyLight(int x, int y, int z, int fallback) { + return this.skyLight == null ? fallback : this.skyLight.get(x, y, z); + } + + /** + * Reads block light. + * + * @param x Local X. + * @param y Local Y. + * @param z Local Z. + * @return Light level 0-15. + */ + public int blockLight(int x, int y, int z) { + return this.blockLight == null ? 0 : this.blockLight.get(x, y, z); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java new file mode 100644 index 0000000..a108624 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java @@ -0,0 +1,89 @@ +package dev.loat.msmp_vision.world; + +import dev.loat.msmp_vision.render.Camera; +import dev.loat.msmp_vision.render.RenderSettings; +import net.minecraft.core.SectionPos; +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.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * Copies the chunk sections around the camera into a {@link WorldSnapshot}. Must run on the server thread. + */ +public final class SnapshotCapture { + + private SnapshotCapture() {} + + /** + * Captures all loaded, potentially visible sections. + * + * @param level The level. + * @param camera The camera used for frustum culling. + * @param settings The render settings. + * @return The snapshot. + */ + 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); + 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, + 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(); + LevelLightEngine light = level.getLightEngine(); + + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); + if (chunk == null) { + 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) + )); + } + } + } + return snapshot; + } + + 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); + double dz = (cz << 4) + 8 - settings.position().get(2); + return dx * dx + dy * dy + dz * dz <= radius * radius && camera.intersectsSection(cx, sy, cz); + } + + private static DataLayer copyLayer(LevelLightEngine engine, LightLayer layer, SectionPos pos) { + DataLayer data = engine.getLayerListener(layer).getDataLayerData(pos); + return data == null ? null : data.copy(); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java new file mode 100644 index 0000000..ca1d16a --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java @@ -0,0 +1,104 @@ +package dev.loat.msmp_vision.world; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.Holder; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Collection; + +/** + * A set of {@link SectionSnapshot}s plus environment data. Built on the server thread, afterwards read-only. + */ +public final class WorldSnapshot { + + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private final EnvironmentSnapshot environment; + private final Long2ObjectOpenHashMap sections = new Long2ObjectOpenHashMap<>(); + + /** + * Creates an empty snapshot. + * + * @param environment Environment values. + */ + public WorldSnapshot(EnvironmentSnapshot environment) { + this.environment = environment; + } + + /** + * Adds a section. Only call while building. + * + * @param section The section. + */ + public void put(SectionSnapshot section) { + this.sections.put(SectionPos.asLong(section.sectionX(), section.sectionY(), section.sectionZ()), section); + } + + /** @return Environment values. */ + public EnvironmentSnapshot environment() { return this.environment; } + + /** @return All captured sections. */ + public Collection sections() { return this.sections.values(); } + + /** + * Reads a block in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return The state, air if the position was not captured. + */ + public BlockState blockState(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? AIR : s.block(x & 15, y & 15, z & 15); + } + + /** + * Reads sky light in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Light 0-15. + */ + public int skyLight(int x, int y, int z) { + if (!this.environment.hasSkyLight()) { + return 0; + } + SectionSnapshot s = this.section(x, y, z); + return s == null ? 15 : s.skyLight(x & 15, y & 15, z & 15, 15); + } + + /** + * Reads block light in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Light 0-15. + */ + public int blockLight(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? 0 : s.blockLight(x & 15, y & 15, z & 15); + } + + /** + * Reads a biome in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return The biome, or {@code null} if the position was not captured. + */ + public Holder biome(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? null : s.biome(x & 15, y & 15, z & 15); + } + + private SectionSnapshot section(int x, int y, int z) { + return this.sections.get(SectionPos.asLong(x >> 4, y >> 4, z >> 4)); + } +} From bae7f47b130e6bde74b1f51cb8bd2d457b2d8ee2 Mon Sep 17 00:00:00 2001 From: Mqx <62719703+Mqxx@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:02:31 +0200 Subject: [PATCH 04/18] feat: cleanup and refactor --- .github/ISSUE_TEMPLATE/.gitkeep | 0 .../new_method_or_parameter_suggestion.yml | 94 +++++++++++ README.md | 158 +++++++++++++++++- .../dev/loat/msmp_vision/VisionService.java | 18 +- .../config/files/MSMPVisionConfigFile.java | 5 +- .../config/files/defaults/DefaultsConfig.java | 3 + .../config/files/limits/LimitsConfig.java | 8 + .../files/rendering/RenderingConfig.java | 6 +- .../config/files/threads/ThreadsConfig.java | 1 + .../logging/RPCConnectionLogger.java | 25 +++ .../msmp_vision/msmp/endpoints/Endpoints.java | 10 +- .../msmp/endpoints/screenshot/Screenshot.java | 57 ++++++- .../screenshot/ScreenshotRequest.java | 112 ++++++++----- .../screenshot/ScreenshotResponse.java | 31 ++-- .../exceptions/InvalidParamsException.java | 17 ++ .../msmp/exceptions/MSMPException.java | 22 +++ .../exceptions/UnknownDimensionException.java | 14 ++ .../msmp_vision/render/RenderExecutor.java | 5 +- .../msmp_vision/render/RenderSettings.java | 59 ++++--- src/main/resources/fabric.mod.json | 6 +- 20 files changed, 551 insertions(+), 100 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/.gitkeep create mode 100644 .github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml create mode 100644 src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java create mode 100644 src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java diff --git a/.github/ISSUE_TEMPLATE/.gitkeep b/.github/ISSUE_TEMPLATE/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml b/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml new file mode 100644 index 0000000..8b78f90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml @@ -0,0 +1,94 @@ +name: 'New Method/Parameter Suggestion' +description: Suggest a new MSMP RPC method, a new request parameter or a rendering feature. +title: '[Suggestion] ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest an improvement! + + Please follow the existing naming and structure conventions where possible, ex.: + - `vision:foo` - the method, called by the client + - vectors are arrays like in Minecraft: `"position": [x, y, z]`, `"rotation": [yaw, pitch]` + - parameter names are `snake_case`, config keys are `kebab-case` + + You don't need to fill out every field perfectly - just give us as much detail as you can. + + - type: dropdown + id: kind + attributes: + label: 'What are you suggesting?' + options: + - 'New method (request/response)' + - 'New parameter for an existing method' + - 'Rendering feature (ex. entities, better lighting)' + - 'Not sure/something else' + + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: 'Use Case' + description: | + Why do you want this? What tooling, dashboard, or automation would this enable? + + validations: + required: true + + - type: textarea + id: description + attributes: + label: 'What should it do?' + description: | + Describe the functionality in detail. What data does it accept or return? + + validations: + required: true + + - type: textarea + id: example-payload + attributes: + label: 'Example request payload' + render: json + description: | + If you have an idea of the JSON shape, share it here. Not required, but it helps a lot. + + placeholder: | + { + "jsonrpc": "2.0", + "method": "vision:foo", + "params": [{ + "position": [0.5, 80.0, 0.5], + "rotation": [90.0, 0.0] + }], + "id": 1 + } + + validations: + required: false + + - type: dropdown + id: needs-config + attributes: + label: 'Would this need configurable settings?' + options: + - 'No, it should just work without configuration' + - 'Yes, it would need some configurable settings (defaults/limits)' + - 'Not sure' + + validations: + required: true + + - type: checkboxes + id: checks + attributes: + label: 'Checks' + options: + - label: "I searched existing issues and this hasn't been suggested yet" + required: true + + - label: 'This is a request for this mod specifically (not Minecraft itself or another mod)' + required: false diff --git a/README.md b/README.md index 5f99f33..fb9d7fe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,159 @@ -# MSMP Vision Mod +# 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 + 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. + # @default: 256 + width: 256 + # Output height in pixels. + # @default: 256 + height: 256 + # 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 + # Biome tint blend radius in blocks (0 = off). + # @default: 1 + biome-blend: 1 +# Upper limits that requests are validated against. Requests exceeding them are rejected. +limits: + # Maximum output width. + # @default: 1920 + max-width: 1920 + # Maximum output height. + # @default: 1080 + max-height: 1080 + # Maximum render distance in blocks. + # @default: 128 + max-render-distance: 128 + # Maximum supersampling factor. + # @default: 3 + max-ssaa: 3 + # Maximum number of internally rendered pixels (width * height * ssaa^2). + # @default: 8000000 + max-rendered-pixels: 8000000 + # Minimum field of view in degrees. + # @default: 30.0 + min-fov: 30.0 + # Maximum field of view in degrees. + # @default: 110.0 + max-fov: 110.0 +# Tuning options of the rasterizer. +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 + # Skip back-facing triangles. Disable only for debugging. + # @default: true + backface-culling: true +# Maximum time in seconds a single screenshot request may take. +# @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. + + +## 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 | +| `rotation` | `[yaw, pitch]`| no | Camera rotation in degrees (yaw `0` = south, `90` = west; pitch positive = down). Default `[0, 0]` | +| `dimension` | string | no | Dimension resource key. Default `minecraft:overworld` | +| `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 | +| `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", + "id": 1, + "method": "vision:screenshot", + "params": [{ + "position": [128.5, 80.0, -32.3], + "rotation": [90.0, 0.0], + "width": 512, + "height": 288 + }] +} +``` + +Example response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "image": "iVBORw0KGgoAAAANSUhEUg...", + "format": "png", + "width": 512, + "height": 288, + "render_time_ms": 412 + } +} +``` + +Only chunks that are currently loaded are rendered; unloaded areas show 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) diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java index 704ce97..85984f1 100644 --- a/src/main/java/dev/loat/msmp_vision/VisionService.java +++ b/src/main/java/dev/loat/msmp_vision/VisionService.java @@ -9,6 +9,7 @@ import dev.loat.msmp_vision.mesh.TintResolver; 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.UnknownDimensionException; import dev.loat.msmp_vision.render.Camera; import dev.loat.msmp_vision.render.RenderExecutor; import dev.loat.msmp_vision.render.RenderSettings; @@ -25,6 +26,7 @@ import java.awt.image.BufferedImage; import java.nio.file.Path; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -81,7 +83,8 @@ public void stop() { * @param server The server. * @param request The request. * @return The response containing a Base64 PNG. - * @throws IllegalArgumentException for invalid parameters. + * @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 or rendering failed. */ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) { @@ -127,14 +130,17 @@ private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera } } - private ServerLevel resolveLevel(MinecraftServer server, String dimension) { - if (dimension == null || dimension.isBlank()) { + private ServerLevel resolveLevel(MinecraftServer server, Optional dimension) { + if (dimension.isEmpty()) { return server.overworld(); } - ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.parse(dimension)); - ServerLevel level = server.getLevel(key); + Identifier identifier = Identifier.tryParse(dimension.get()); + if (identifier == null) { + throw new UnknownDimensionException(dimension.get()); + } + ServerLevel level = server.getLevel(ResourceKey.create(Registries.DIMENSION, identifier)); if (level == null) { - throw new IllegalArgumentException("Unknown dimension: " + dimension); + throw new UnknownDimensionException(dimension.get()); } return level; } 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 c9c5d92..9a5fd7d 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 @@ -20,12 +20,13 @@ private MSMPVisionConfigFile() {} @Annotation.Comment("Values used when a request does not specify a setting.") public DefaultsConfig defaults = new DefaultsConfig(); - @Annotation.Comment("Upper limits which requests are clamped to.") + @Annotation.Comment("Upper limits that requests are validated against. Requests exceeding them are rejected.") 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; } 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 b1a5877..c7aabf2 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 @@ -4,6 +4,7 @@ public class DefaultsConfig { + @Annotation.Comment("Output width in pixels.") public int width = 256; @@ -14,11 +15,13 @@ public class DefaultsConfig { public double fov = 70.0; @Annotation.Comment("Render distance in blocks.") + @Annotation.Key("render-distance") public int renderDistance = 64; @Annotation.Comment("Supersampling factor (1 = off).") public int ssaa = 1; @Annotation.Comment("Biome tint blend radius in blocks (0 = off).") + @Annotation.Key("biome-blend") public int biomeBlend = 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 7dd0f9f..9aac099 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 @@ -4,24 +4,32 @@ public class LimitsConfig { + @Annotation.Comment("Maximum output width.") + @Annotation.Key("max-width") public int maxWidth = 1920; @Annotation.Comment("Maximum output height.") + @Annotation.Key("max-height") public int maxHeight = 1080; @Annotation.Comment("Maximum render distance in blocks.") + @Annotation.Key("max-render-distance") public int maxRenderDistance = 128; @Annotation.Comment("Maximum supersampling factor.") + @Annotation.Key("max-ssaa") public int maxSsaa = 3; @Annotation.Comment("Maximum number of internally rendered pixels (width * height * ssaa^2).") + @Annotation.Key("max-rendered-pixels") public int maxRenderedPixels = 8_000_000; @Annotation.Comment("Minimum field of view in degrees.") + @Annotation.Key("min-fov") public double minFov = 30.0; @Annotation.Comment("Maximum field of view in degrees.") + @Annotation.Key("max-fov") public double maxFov = 110.0; } 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/rendering/RenderingConfig.java index a6800bc..7e6b979 100644 --- a/src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java +++ b/src/main/java/dev/loat/msmp_vision/config/files/rendering/RenderingConfig.java @@ -4,12 +4,16 @@ public class RenderingConfig { + @Annotation.Comment("Edge length of a raster tile in pixels. One tile is one parallel work unit.") + @Annotation.Key("tile-size") public int tileSize = 64; @Annotation.Comment("Fog starts at this fraction of the render distance.") + @Annotation.Key("fog-start-factor") public double fogStartFactor = 0.75; - @Annotation.Comment("Skip back-facing opaque triangles. Disable only for debugging.") + @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/config/files/threads/ThreadsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/threads/ThreadsConfig.java index 4206a24..e67163e 100644 --- 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 @@ -4,6 +4,7 @@ public class ThreadsConfig { + @Annotation.Comment(""" Number of render worker threads (meshing and rasterization). 0 = automatic (available processors - 1, at least 1). diff --git a/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java new file mode 100644 index 0000000..d13f39d --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java @@ -0,0 +1,25 @@ +package dev.loat.msmp_vision.logging; + +/** + * Logger that prefixes every message with the ID of the RPC connection it belongs to. + */ +public class RPCConnectionLogger { + + private RPCConnectionLogger() {} + + public static void debug(Integer connectionId, String message) { + Logger.debug("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void info(Integer connectionId, String message) { + Logger.info("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void warning(Integer connectionId, String message) { + Logger.warning("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void error(Integer connectionId, String message) { + Logger.error("RPC Connection #%s: %s".formatted(connectionId, message)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java index 0f46fe1..f0b732a 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java @@ -8,6 +8,11 @@ import java.util.function.Supplier; +/** + * Central registration point for all {@code vision} MSMP endpoints. + * + *

Each endpoint is implemented in its own sub-package and registered here.

+ */ public class Endpoints { private Endpoints() {} @@ -15,10 +20,11 @@ private Endpoints() {} * Registers all endpoints on the given {@link MSMPNamespace}. * * @param namespace The namespace to register all endpoints under - * @param msmpServer A supplier for the MSMPServer instance, used by some endpoints to subscribe to server events + * @param msmpServer A supplier for the MSMPServer instance, used by endpoints that push notifications + * @param service The service that renders the screenshots */ public static void register(MSMPNamespace namespace, 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