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 1/3] 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 2/3] 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 3/3] 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 extends CompletableFuture>> 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));
+ }
+}