diff --git a/src/main/java/dev/loat/msmp_vision/MSMPVision.java b/src/main/java/dev/loat/msmp_vision/MSMPVision.java index 252abf6..6eb9f66 100644 --- a/src/main/java/dev/loat/msmp_vision/MSMPVision.java +++ b/src/main/java/dev/loat/msmp_vision/MSMPVision.java @@ -1,17 +1,37 @@ 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 NAMESPACE = new MSMPNamespace("vision"); + private final VisionService service = new VisionService(); + + /** + * 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 +39,18 @@ public void onInitialize() { Config.register(); + Endpoints.register(NAMESPACE, () -> msmp, this.service); + ServerLifecycleEvents.SERVER_STARTED.register(server -> { - + NAMESPACE.attach(server); + msmp = new MSMPServer(server); + this.service.start(); }); ServerLifecycleEvents.SERVER_STOPPED.register(server -> { - + this.service.stop(); + NAMESPACE.detach(); + msmp = null; }); Logger.info("Mod initialized."); 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/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/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/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/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); + } +} 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..0f46fe1 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java @@ -0,0 +1,24 @@ +package dev.loat.msmp_vision.msmp.endpoints; + +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; + + +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, 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 new file mode 100644 index 0000000..a830969 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java @@ -0,0 +1,25 @@ +package dev.loat.msmp_vision.msmp.endpoints.screenshot; + +import dev.loat.msmp.MSMPNamespace; +import dev.loat.msmp_vision.VisionService; + + +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, 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) -> service.screenshot(server, request)); + } +} 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); +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/Fog.java b/src/main/java/dev/loat/msmp_vision/raster/Fog.java new file mode 100644 index 0000000..fbfc5f5 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/Fog.java @@ -0,0 +1,43 @@ +package dev.loat.msmp_vision.raster; + +/** + * Linear distance fog. + * + * @param start Distance where fog begins. + * @param end Distance of full fog. + * @param r Fog red. + * @param g Fog green. + * @param b Fog blue. + */ +public record Fog( + float start, + float end, + int r, + int g, + int b +) { + + /** + * Creates fog from a packed colour. + * + * @param start Start distance. + * @param end End distance. + * @param rgb Colour as {@code 0xRRGGBB}. + * @return The fog. + */ + public static Fog of(float start, float end, int rgb) { + return new Fog(start, end, (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + } + + /** + * @param distance View-space depth. + * @return Visibility 1 (no fog) to 0 (full fog). + */ + public float factor(float distance) { + float range = this.end - this.start; + if (range <= 0) { + return distance < this.end ? 1f : 0f; + } + return Math.max(0f, Math.min(1f, (this.end - distance) / range)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java new file mode 100644 index 0000000..105f1a7 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java @@ -0,0 +1,72 @@ +package dev.loat.msmp_vision.raster; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; + +/** + * Colour and depth storage. Depth stores {@code 1/w}, so larger means closer and {@code 0} means empty. + */ +public final class Framebuffer { + + private final int width; + private final int height; + private final int[] color; + private final float[] depth; + + /** + * Creates a cleared framebuffer. + * + * @param width Width in pixels. + * @param height Height in pixels. + */ + public Framebuffer(int width, int height) { + this.width = width; + this.height = height; + this.color = new int[width * height]; + this.depth = new float[width * height]; + } + + /** @return Width. */ + public int width() { return this.width; } + + /** @return Height. */ + public int height() { return this.height; } + + /** @return Colour buffer ({@code 0xFFRRGGBB}). */ + public int[] color() { return this.color; } + + /** @return Depth buffer ({@code 1/w}). */ + public float[] depth() { return this.depth; } + + /** + * Downsamples with a box filter. + * + * @param factor Supersampling factor. + * @return The final image. + */ + public BufferedImage toImage(int factor) { + int outWidth = this.width / factor; + int outHeight = this.height / factor; + BufferedImage image = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_INT_RGB); + int[] target = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int samples = factor * factor; + for (int y = 0; y < outHeight; y++) { + for (int x = 0; x < outWidth; x++) { + int r = 0; + int g = 0; + int b = 0; + for (int sy = 0; sy < factor; sy++) { + int row = (y * factor + sy) * this.width + x * factor; + for (int sx = 0; sx < factor; sx++) { + int pixel = this.color[row + sx]; + r += (pixel >> 16) & 0xFF; + g += (pixel >> 8) & 0xFF; + b += pixel & 0xFF; + } + } + target[y * outWidth + x] = ((r / samples) << 16) | ((g / samples) << 8) | (b / samples); + } + } + return image; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java new file mode 100644 index 0000000..c09c84a --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java @@ -0,0 +1,147 @@ +package dev.loat.msmp_vision.raster; + +import dev.loat.msmp_vision.mesh.QuadBuffer; + +/** + * Transforms quads into clipped, back-face culled screen-space triangles. + * + *

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

+ */ +public final class GeometryProjector { + + private static final float NEAR_W = 0.05f; + private static final int V = TriangleBuffer.VERTEX; + + private final float[] m; + private final int width; + private final int height; + private final boolean cull; + private final float[][] quad = new float[4][V]; + private final float[][] input = new float[3][]; + private final float[][] clipped = new float[5][V]; + private final float[][] screen = new float[5][V]; + + /** + * Creates a projector. + * + * @param viewProjection Column-major view-projection matrix. + * @param width Framebuffer width. + * @param height Framebuffer height. + * @param backfaceCulling Whether back faces are dropped. + */ + public GeometryProjector(float[] viewProjection, int width, int height, boolean backfaceCulling) { + this.m = viewProjection; + this.width = width; + this.height = height; + this.cull = backfaceCulling; + } + + /** + * Projects all quads of a buffer. + * + * @param quads The quads. + * @return Screen-space triangles. + */ + public TriangleBuffer project(QuadBuffer quads) { + TriangleBuffer out = new TriangleBuffer(); + for (int q = 0; q < quads.count(); q++) { + if (!this.load(quads, q)) { + continue; + } + int texture = quads.texture(q); + this.emit(out, texture, this.quad[0], this.quad[1], this.quad[2]); + this.emit(out, texture, this.quad[0], this.quad[2], this.quad[3]); + } + return out; + } + + /** Loads a quad into clip space; returns {@code false} if it is trivially invisible. */ + private boolean load(QuadBuffer quads, int q) { + float[] pos = quads.positions(); + float[] uv = quads.uvs(); + int color = quads.color(q); + float r = ((color >> 16) & 0xFF) / 255f; + float g = ((color >> 8) & 0xFF) / 255f; + float b = (color & 0xFF) / 255f; + + boolean left = true, right = true, below = true, above = true, behind = true; + for (int v = 0; v < 4; v++) { + int i = q * 12 + v * 3; + float px = pos[i], py = pos[i + 1], pz = pos[i + 2]; + float x = this.m[0] * px + this.m[4] * py + this.m[8] * pz + this.m[12]; + float y = this.m[1] * px + this.m[5] * py + this.m[9] * pz + this.m[13]; + float w = this.m[3] * px + this.m[7] * py + this.m[11] * pz + this.m[15]; + float[] c = this.quad[v]; + c[0] = x; + c[1] = y; + c[2] = w; + c[3] = uv[q * 8 + v * 2]; + c[4] = uv[q * 8 + v * 2 + 1]; + c[5] = r; + c[6] = g; + c[7] = b; + if (x >= -w) left = false; + if (x <= w) right = false; + if (y >= -w) below = false; + if (y <= w) above = false; + if (w >= NEAR_W) behind = false; + } + return !(left || right || below || above || behind); + } + + private void emit(TriangleBuffer out, int texture, float[] a, float[] b, float[] c) { + this.input[0] = a; + this.input[1] = b; + this.input[2] = c; + int n = this.clipNear(); + if (n < 3) { + return; + } + for (int i = 0; i < n; i++) { + float[] s = this.clipped[i]; + float[] d = this.screen[i]; + float invW = 1f / s[2]; + d[0] = (s[0] * invW * 0.5f + 0.5f) * this.width; + d[1] = (0.5f - s[1] * invW * 0.5f) * this.height; + d[2] = invW; + d[3] = s[3] * invW; + d[4] = s[4] * invW; + d[5] = s[5]; + d[6] = s[6]; + d[7] = s[7]; + } + for (int i = 1; i < n - 1; i++) { + float[] p = this.screen[0]; + float[] q = this.screen[i]; + float[] r = this.screen[i + 1]; + double area = (double) (q[0] - p[0]) * (r[1] - p[1]) - (double) (q[1] - p[1]) * (r[0] - p[0]); + if (area == 0 || (this.cull && area > 0)) { + continue; + } + float depth = (1f / p[2] + 1f / q[2] + 1f / r[2]) / 3f; + out.add(texture, p, q, r, depth); + } + } + + /** Sutherland-Hodgman clip of {@link #input} against {@code w >= NEAR_W} into {@link #clipped}. */ + private int clipNear() { + int n = 0; + for (int i = 0; i < 3; i++) { + float[] current = this.input[i]; + float[] next = this.input[(i + 1) % 3]; + boolean currentIn = current[2] >= NEAR_W; + boolean nextIn = next[2] >= NEAR_W; + if (currentIn) { + System.arraycopy(current, 0, this.clipped[n++], 0, V); + } + if (currentIn != nextIn) { + float t = (NEAR_W - current[2]) / (next[2] - current[2]); + float[] dest = this.clipped[n++]; + for (int k = 0; k < V; k++) { + dest[k] = current[k] + (next[k] - current[k]) * t; + } + } + } + return n; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java new file mode 100644 index 0000000..6a20ae8 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java @@ -0,0 +1,211 @@ +package dev.loat.msmp_vision.raster; + +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.render.RenderExecutor; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Multi-threaded tile based triangle rasterizer. Triangles are binned per tile, each tile is an independent job + * (opaque pass first, then translucent triangles back-to-front), so no synchronization is needed on the framebuffer. + */ +public final class TileRasterizer { + + private static final int T = TriangleBuffer.TRIANGLE; + private static final int V = TriangleBuffer.VERTEX; + + private final RenderExecutor executor; + private final int tileSize; + private final long timeoutSeconds; + + /** + * Creates a rasterizer. + * + * @param executor Worker pool. + * @param tileSize Tile edge length in pixels. + * @param timeoutSeconds Maximum time for the whole raster pass. + */ + public TileRasterizer(RenderExecutor executor, int tileSize, long timeoutSeconds) { + this.executor = executor; + this.tileSize = Math.max(8, tileSize); + this.timeoutSeconds = timeoutSeconds; + } + + /** + * Rasterizes both layers into the framebuffer. + * + * @param opaque Opaque triangles (any order). + * @param translucent Translucent triangles (sorted internally). + * @param target The framebuffer, may already contain a sky. + * @param textures Textures indexed by texture id. + * @param fog Distance fog. + */ + public void rasterize(TriangleBuffer opaque, TriangleBuffer translucent, Framebuffer target, TextureData[] textures, Fog fog) { + int tilesX = (target.width() + this.tileSize - 1) / this.tileSize; + int tilesY = (target.height() + this.tileSize - 1) / this.tileSize; + + IntArrayList[] opaqueBins = this.bin(opaque, null, tilesX, tilesY); + IntArrayList[] translucentBins = this.bin(translucent, this.sortBackToFront(translucent), tilesX, tilesY); + + List> jobs = new ArrayList<>(tilesX * tilesY); + for (int ty = 0; ty < tilesY; ty++) { + for (int tx = 0; tx < tilesX; tx++) { + int bin = ty * tilesX + tx; + if (opaqueBins[bin] == null && translucentBins[bin] == null) { + continue; + } + int x0 = tx * this.tileSize; + int y0 = ty * this.tileSize; + int x1 = Math.min(target.width(), x0 + this.tileSize); + int y1 = Math.min(target.height(), y0 + this.tileSize); + jobs.add(CompletableFuture.runAsync(() -> { + this.drawBin(opaque, opaqueBins[bin], false, x0, y0, x1, y1, target, textures, fog); + this.drawBin(translucent, translucentBins[bin], true, x0, y0, x1, y1, target, textures, fog); + }, this.executor.executor())); + } + } + this.executor.awaitAll(jobs, this.timeoutSeconds); + } + + private int[] sortBackToFront(TriangleBuffer buffer) { + long[] keys = new long[buffer.count()]; + for (int i = 0; i < keys.length; i++) { + keys[i] = ((long) Float.floatToIntBits(buffer.depth(i)) << 32) | i; + } + Arrays.sort(keys); + int[] order = new int[keys.length]; + for (int i = 0; i < keys.length; i++) { + order[i] = (int) keys[keys.length - 1 - i]; + } + return order; + } + + private IntArrayList[] bin(TriangleBuffer buffer, int[] order, int tilesX, int tilesY) { + IntArrayList[] bins = new IntArrayList[tilesX * tilesY]; + float[] d = buffer.data(); + for (int n = 0; n < buffer.count(); n++) { + int i = order == null ? n : order[n]; + int o = i * T; + float minX = Math.min(d[o], Math.min(d[o + V], d[o + 2 * V])); + float maxX = Math.max(d[o], Math.max(d[o + V], d[o + 2 * V])); + float minY = Math.min(d[o + 1], Math.min(d[o + V + 1], d[o + 2 * V + 1])); + float maxY = Math.max(d[o + 1], Math.max(d[o + V + 1], d[o + 2 * V + 1])); + int tx0 = Math.max(0, (int) Math.floor(minX) / this.tileSize); + int tx1 = Math.min(tilesX - 1, (int) Math.floor(maxX) / this.tileSize); + int ty0 = Math.max(0, (int) Math.floor(minY) / this.tileSize); + int ty1 = Math.min(tilesY - 1, (int) Math.floor(maxY) / this.tileSize); + if (maxX < 0 || maxY < 0) { + continue; + } + for (int ty = ty0; ty <= ty1; ty++) { + for (int tx = tx0; tx <= tx1; tx++) { + int bin = ty * tilesX + tx; + if (bins[bin] == null) { + bins[bin] = new IntArrayList(); + } + bins[bin].add(i); + } + } + } + return bins; + } + + private void drawBin(TriangleBuffer buffer, IntArrayList bin, boolean translucentPass, int x0, int y0, int x1, int y1, + Framebuffer target, TextureData[] textures, Fog fog) { + if (bin == null) { + return; + } + for (int n = 0; n < bin.size(); n++) { + int i = bin.getInt(n); + TextureData texture = textures[buffer.texture(i)]; + if (texture != null) { + this.drawTriangle(buffer.data(), i * T, texture, translucentPass, x0, y0, x1, y1, target, fog); + } + } + } + + private void drawTriangle(float[] d, int o, TextureData texture, boolean translucentPass, int x0, int y0, int x1, int y1, + Framebuffer target, Fog fog) { + float ax = d[o], ay = d[o + 1], aw = d[o + 2], au = d[o + 3], av = d[o + 4], ar = d[o + 5], ag = d[o + 6], ab = d[o + 7]; + float bx = d[o + V], by = d[o + V + 1], bw = d[o + V + 2], bu = d[o + V + 3], bv = d[o + V + 4], br = d[o + V + 5], bg = d[o + V + 6], bb = d[o + V + 7]; + float cx = d[o + 2 * V], cy = d[o + 2 * V + 1], cw = d[o + 2 * V + 2], cu = d[o + 2 * V + 3], cv = d[o + 2 * V + 4], cr = d[o + 2 * V + 5], cg = d[o + 2 * V + 6], cb = d[o + 2 * V + 7]; + + double area = (double) (bx - ax) * (cy - ay) - (double) (by - ay) * (cx - ax); + if (area == 0) { + return; + } + double inv = 1.0 / area; + int minX = Math.max(x0, (int) Math.floor(Math.min(ax, Math.min(bx, cx)))); + int maxX = Math.min(x1 - 1, (int) Math.ceil(Math.max(ax, Math.max(bx, cx)))); + int minY = Math.max(y0, (int) Math.floor(Math.min(ay, Math.min(by, cy)))); + int maxY = Math.min(y1 - 1, (int) Math.ceil(Math.max(ay, Math.max(by, cy)))); + if (minX > maxX || minY > maxY) { + return; + } + + double px = minX + 0.5; + double py = minY + 0.5; + double e0Row = (cx - bx) * (py - by) - (cy - by) * (px - bx); + double e1Row = (ax - cx) * (py - cy) - (ay - cy) * (px - cx); + double e2Row = (bx - ax) * (py - ay) - (by - ay) * (px - ax); + double e0dx = -(cy - by), e0dy = cx - bx; + double e1dx = -(ay - cy), e1dy = ax - cx; + double e2dx = -(by - ay), e2dy = bx - ax; + + int[] color = target.color(); + float[] depth = target.depth(); + int width = target.width(); + + for (int y = minY; y <= maxY; y++) { + double e0 = e0Row, e1 = e1Row, e2 = e2Row; + for (int x = minX; x <= maxX; x++) { + double w0 = e0 * inv, w1 = e1 * inv, w2 = e2 * inv; + e0 += e0dx; + e1 += e1dx; + e2 += e2dx; + if (w0 < 0 || w1 < 0 || w2 < 0) { + continue; + } + int idx = y * width + x; + float invW = (float) (w0 * aw + w1 * bw + w2 * cw); + if (invW <= depth[idx]) { + continue; + } + float w = 1f / invW; + float u = (float) (w0 * au + w1 * bu + w2 * cu) * w; + float v = (float) (w0 * av + w1 * bv + w2 * cv) * w; + int texel = texture.sample(u, v); + int alpha = texel >>> 24; + if (alpha == 0) { + continue; + } + float lr = (float) (w0 * ar + w1 * br + w2 * cr); + float lg = (float) (w0 * ag + w1 * bg + w2 * cg); + float lb = (float) (w0 * ab + w1 * bb + w2 * cb); + float f = fog.factor(w); + int r = Math.min(255, (int) (((texel >> 16) & 0xFF) * lr * f + fog.r() * (1f - f))); + int g = Math.min(255, (int) (((texel >> 8) & 0xFF) * lg * f + fog.g() * (1f - f))); + int b = Math.min(255, (int) ((texel & 0xFF) * lb * f + fog.b() * (1f - f))); + + if (!translucentPass || alpha == 255) { + color[idx] = 0xFF000000 | (r << 16) | (g << 8) | b; + depth[idx] = invW; + } else { + int dst = color[idx]; + int inverse = 255 - alpha; + int or = (r * alpha + ((dst >> 16) & 0xFF) * inverse) / 255; + int og = (g * alpha + ((dst >> 8) & 0xFF) * inverse) / 255; + int ob = (b * alpha + (dst & 0xFF) * inverse) / 255; + color[idx] = 0xFF000000 | (or << 16) | (og << 8) | ob; + } + } + e0Row += e0dy; + e1Row += e1dy; + e2Row += e2dy; + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java b/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java new file mode 100644 index 0000000..f161546 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/raster/TriangleBuffer.java @@ -0,0 +1,88 @@ +package dev.loat.msmp_vision.raster; + +import java.util.Arrays; +import java.util.List; + + +/** + * Screen-space triangles. Per vertex 8 floats: {@code x, y, 1/w, u/w, v/w, r, g, b}. + */ +public final class TriangleBuffer { + + /** Floats per vertex. */ + public static final int VERTEX = 8; + /** Floats per triangle. */ + public static final int TRIANGLE = VERTEX * 3; + + private float[] data = new float[TRIANGLE * 64]; + private int[] textures = new int[64]; + private float[] depths = new float[64]; + private int count; + + /** + * Appends a triangle. + * + * @param texture Texture id. + * @param a First vertex. + * @param b Second vertex. + * @param c Third vertex. + * @param depth Average view depth, used for sorting. + */ + public void add(int texture, float[] a, float[] b, float[] c, float depth) { + if (this.count == this.textures.length) { + int capacity = this.count * 2; + this.data = Arrays.copyOf(this.data, capacity * TRIANGLE); + this.textures = Arrays.copyOf(this.textures, capacity); + this.depths = Arrays.copyOf(this.depths, capacity); + } + int o = this.count * TRIANGLE; + System.arraycopy(a, 0, this.data, o, VERTEX); + System.arraycopy(b, 0, this.data, o + VERTEX, VERTEX); + System.arraycopy(c, 0, this.data, o + 2 * VERTEX, VERTEX); + this.textures[this.count] = texture; + this.depths[this.count] = depth; + this.count++; + } + + /** @return Triangle count. */ + public int count() { return this.count; } + + /** @return Vertex data. */ + public float[] data() { return this.data; } + + /** + * @param triangle Triangle index. + * @return Texture id. + */ + public int texture(int triangle) { return this.textures[triangle]; } + + /** + * @param triangle Triangle index. + * @return Average view depth. + */ + public float depth(int triangle) { return this.depths[triangle]; } + + /** + * Concatenates buffers. + * + * @param buffers The buffers. + * @return A new buffer containing all triangles. + */ + public static TriangleBuffer merge(List buffers) { + int total = 0; + for (TriangleBuffer buffer : buffers) { + total += buffer.count; + } + TriangleBuffer merged = new TriangleBuffer(); + merged.data = new float[Math.max(1, total) * TRIANGLE]; + merged.textures = new int[Math.max(1, total)]; + merged.depths = new float[Math.max(1, total)]; + for (TriangleBuffer buffer : buffers) { + System.arraycopy(buffer.data, 0, merged.data, merged.count * TRIANGLE, buffer.count * TRIANGLE); + System.arraycopy(buffer.textures, 0, merged.textures, merged.count, buffer.count); + System.arraycopy(buffer.depths, 0, merged.depths, merged.count, buffer.count); + merged.count += buffer.count; + } + return merged; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/Camera.java b/src/main/java/dev/loat/msmp_vision/render/Camera.java new file mode 100644 index 0000000..f5c6518 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/Camera.java @@ -0,0 +1,100 @@ +package dev.loat.msmp_vision.render; + +import org.joml.FrustumIntersection; +import org.joml.Matrix4f; +import org.joml.Vector3f; + +/** + * Perspective camera. All geometry is expressed relative to an integer origin near the camera so that + * 32-bit floats stay precise far from the world origin. + */ +public final class Camera { + + private final int originX; + private final int originY; + private final int originZ; + private final Vector3f forward; + private final Vector3f right; + private final Vector3f up; + private final float tanHalfFov; + private final float aspect; + private final float[] viewProjection = new float[16]; + private final FrustumIntersection frustum; + + /** + * Creates a camera from render settings. + * + * @param settings The settings. + */ + public Camera(RenderSettings settings) { + this.originX = (int) Math.floor(settings.position().get(0)); + this.originY = (int) Math.floor(settings.position().get(1)); + this.originZ = (int) Math.floor(settings.position().get(2)); + + double yaw = Math.toRadians(settings.yaw()); + double pitch = Math.toRadians(settings.pitch()); + this.forward = new Vector3f( + (float) (-Math.sin(yaw) * Math.cos(pitch)), + (float) -Math.sin(pitch), + (float) (Math.cos(yaw) * Math.cos(pitch)) + ); + Vector3f worldUp = new Vector3f(0, 1, 0); + this.right = new Vector3f(this.forward).cross(worldUp).normalize(); + this.up = new Vector3f(this.right).cross(this.forward).normalize(); + this.aspect = (float) settings.width() / settings.height(); + this.tanHalfFov = (float) Math.tan(Math.toRadians(settings.fov()) / 2.0); + + Vector3f eye = new Vector3f( + (float) (settings.position().get(0) - this.originX), + (float) (settings.position().get(1) - this.originY), + (float) (settings.position().get(2) - this.originZ) + ); + Matrix4f matrix = new Matrix4f() + .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistance() + 64f) + .lookAt(eye, new Vector3f(eye).add(this.forward), new Vector3f(0, 1, 0)); + matrix.get(this.viewProjection); + this.frustum = new FrustumIntersection(matrix); + } + + /** @return X of the coordinate origin all geometry is relative to. */ + public int originX() { return this.originX; } + + /** @return Y of the coordinate origin. */ + public int originY() { return this.originY; } + + /** @return Z of the coordinate origin. */ + public int originZ() { return this.originZ; } + + /** @return Normalized view direction. */ + public Vector3f forward() { return this.forward; } + + /** @return Normalized right vector. */ + public Vector3f right() { return this.right; } + + /** @return Normalized up vector. */ + public Vector3f up() { return this.up; } + + /** @return {@code tan(fov / 2)}. */ + public float tanHalfFov() { return this.tanHalfFov; } + + /** @return Width divided by height. */ + public float aspect() { return this.aspect; } + + /** @return Column-major view-projection matrix (16 floats). */ + public float[] viewProjection() { return this.viewProjection; } + + /** + * Tests whether a 16-block section may be visible. + * + * @param sectionX Section X. + * @param sectionY Section Y. + * @param sectionZ Section Z. + * @return {@code false} if the section is certainly outside the view frustum. + */ + public boolean intersectsSection(int sectionX, int sectionY, int sectionZ) { + float minX = (sectionX << 4) - this.originX; + float minY = (sectionY << 4) - this.originY; + float minZ = (sectionZ << 4) - this.originZ; + return this.frustum.testAab(minX, minY, minZ, minX + 16, minY + 16, minZ + 16); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java new file mode 100644 index 0000000..ade7cda --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java @@ -0,0 +1,68 @@ +package dev.loat.msmp_vision.render; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Daemon thread pool used for meshing and rasterization. + */ +public final class RenderExecutor { + + private final ExecutorService pool; + private final int threads; + + /** + * Creates the pool. + * + * @param configuredThreads Thread count, {@code 0} selects the number of available processors. + */ + public RenderExecutor(int configuredThreads) { + this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors()); + AtomicInteger counter = new AtomicInteger(); + this.pool = Executors.newFixedThreadPool(this.threads, runnable -> { + Thread thread = new Thread(runnable, "msmp-vision-render-" + counter.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } + + /** @return The underlying executor. */ + public ExecutorService executor() { return this.pool; } + + /** @return Number of worker threads. */ + public int threads() { return this.threads; } + + /** + * Waits for all jobs, cancelling the rest on timeout or failure. + * + * @param jobs The jobs. + * @param timeoutSeconds Maximum wait. + * @throws IllegalStateException on timeout, failure or interruption. + */ + public void awaitAll(List> jobs, long timeoutSeconds) { + try { + CompletableFuture.allOf(jobs.toArray(new CompletableFuture[0])).get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering timed out after " + timeoutSeconds + "s"); + } catch (ExecutionException e) { + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering failed: " + e.getCause(), e.getCause()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + jobs.forEach(job -> job.cancel(true)); + throw new IllegalStateException("Rendering interrupted"); + } + } + + /** Stops all worker threads. */ + public void shutdown() { + this.pool.shutdownNow(); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java new file mode 100644 index 0000000..99ce9d5 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java @@ -0,0 +1,93 @@ +package dev.loat.msmp_vision.render; + +import java.util.List; + +import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile; +import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig; +import dev.loat.msmp_vision.config.files.limits.LimitsConfig; +import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest; + + +/** + * Immutable, validated parameters of a single render. The only place where request values, config defaults + * and config limits are combined; every renderer stage reads from here. + * + * @param x Camera X. + * @param y Camera Y. + * @param z Camera Z. + * @param yaw Yaw in degrees (Minecraft convention, 0 = south). + * @param pitch Pitch in degrees (positive = down). + * @param width Output width in pixels. + * @param height Output height in pixels. + * @param fov Vertical field of view in degrees. + * @param renderDistance Render distance in blocks. + * @param ssaa Supersampling factor (1 = off). + * @param biomeBlend Biome tint blend radius in blocks. + */ +public record RenderSettings( + List position, + double yaw, + double pitch, + int width, + int height, + double fov, + int renderDistance, + int ssaa, + int biomeBlend +) { + + /** + * Builds validated settings. + * + * @param request The request; zero values select the config default. + * @param config The configuration providing defaults and limits. + * @return The settings. + * @throws IllegalArgumentException if a value exceeds the configured limits. + */ + public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFile config) { + DefaultsConfig defaultsConfig = config.defaults; + LimitsConfig limitsConfig = config.limits; + + int width = request.width() > 0 ? request.width() : defaultsConfig.width; + int height = request.height() > 0 ? request.height() : defaultsConfig.height; + double fov = request.fov() > 0 ? request.fov() : defaultsConfig.fov; + int distance = request.renderDistance() > 0 ? request.renderDistance() : defaultsConfig.renderDistance; + int ssaa = request.ssaa() > 0 ? request.ssaa() : defaultsConfig.ssaa; + int blend = request.biomeBlend() >= 0 ? request.biomeBlend() : defaultsConfig.biomeBlend; + + require(width <= limitsConfig.maxWidth, "width exceeds the limit of " + limitsConfig.maxWidth); + require(height <= limitsConfig.maxHeight, "height exceeds the limit of " + limitsConfig.maxHeight); + require(distance <= limitsConfig.maxRenderDistance, "renderDistance exceeds the limit of " + limitsConfig.maxRenderDistance); + require(ssaa <= limitsConfig.maxSsaa, "ssaa exceeds the limit of " + limitsConfig.maxSsaa); + require(fov >= limitsConfig.minFov && fov <= limitsConfig.maxFov, "fov must be between " + limitsConfig.minFov + " and " + limitsConfig.maxFov); + require((long) width * ssaa * height * ssaa <= limitsConfig.maxRenderedPixels, "width*height*ssaa^2 exceeds " + limitsConfig.maxRenderedPixels + " pixels"); + + return new RenderSettings( + request.position(), + request.yaw(), + request.pitch(), + width, + height, + fov, + distance, + ssaa, + Math.min(blend, 4) + ); + } + + /** @return Framebuffer width including supersampling. */ + public int renderWidth() { + return this.width * this.ssaa; + } + + /** @return Framebuffer height including supersampling. */ + public int renderHeight() { + return this.height * this.ssaa; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java new file mode 100644 index 0000000..aabe3e7 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java @@ -0,0 +1,114 @@ +package dev.loat.msmp_vision.render; + +import dev.loat.msmp_vision.assets.AssetManager; +import dev.loat.msmp_vision.assets.TextureData; +import dev.loat.msmp_vision.assets.TextureStore; +import dev.loat.msmp_vision.config.files.MSMPVisionConfigFile; +import dev.loat.msmp_vision.mesh.SectionGeometry; +import dev.loat.msmp_vision.mesh.SectionMesher; +import dev.loat.msmp_vision.mesh.TintResolver; +import dev.loat.msmp_vision.model.BlockModelBaker; +import dev.loat.msmp_vision.model.BlockStateSelector; +import dev.loat.msmp_vision.model.ModelResolver; +import dev.loat.msmp_vision.raster.Fog; +import dev.loat.msmp_vision.raster.Framebuffer; +import dev.loat.msmp_vision.raster.GeometryProjector; +import dev.loat.msmp_vision.raster.TileRasterizer; +import dev.loat.msmp_vision.raster.TriangleBuffer; +import dev.loat.msmp_vision.world.SectionSnapshot; +import dev.loat.msmp_vision.world.WorldSnapshot; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Orchestrates one render: mesh and project sections in parallel, paint the sky, rasterize tiles in parallel. + * Thread-safe; baked models and textures are cached across renders. + */ +public final class SceneRenderer { + + private record Projected(TriangleBuffer opaque, TriangleBuffer translucent) {} + + private final TextureStore textures; + private final RenderExecutor executor; + private final BlockModelBaker baker; + private final TintResolver tint = new TintResolver(); + + /** + * Creates a renderer. + * + * @param assets Asset source. + * @param textures Texture registry. + * @param executor Worker pool. + */ + public SceneRenderer(AssetManager assets, TextureStore textures, RenderExecutor executor) { + this.textures = textures; + this.executor = executor; + this.baker = new BlockModelBaker(new BlockStateSelector(assets), new ModelResolver(assets), textures); + } + + /** + * Renders a captured world. + * + * @param world The snapshot. + * @param camera The camera. + * @param settings The settings. + * @param config The current configuration. + * @return The final image. + */ + public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { + long timeout = config.requestTimeoutSeconds; + int width = settings.renderWidth(); + int height = settings.renderHeight(); + boolean cull = config.rendering.backfaceCulling; + + List> jobs = new ArrayList<>(); + for (SectionSnapshot section : world.sections()) { + if (section.isEmpty()) { + continue; + } + jobs.add(CompletableFuture.supplyAsync(() -> { + SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend()).mesh(section); + GeometryProjector projector = new GeometryProjector(camera.viewProjection(), width, height, cull); + return new Projected(projector.project(geometry.opaque()), projector.project(geometry.translucent())); + }, this.executor.executor())); + } + this.executor.awaitAll(jobs, timeout); + + List opaque = new ArrayList<>(jobs.size()); + List translucent = new ArrayList<>(jobs.size()); + for (CompletableFuture job : jobs) { + Projected projected = job.join(); + if (projected.opaque().count() > 0) opaque.add(projected.opaque()); + if (projected.translucent().count() > 0) translucent.add(projected.translucent()); + } + + Framebuffer framebuffer = new Framebuffer(width, height); + SkyPainter.paint(framebuffer, camera, world.environment()); + + float fogEnd = settings.renderDistance(); + Fog fog = Fog.of((float) (fogEnd * config.rendering.fogStartFactor), fogEnd, world.environment().fogColor()); + new TileRasterizer(this.executor, config.rendering.tileSize, timeout) + .rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog); + return framebuffer.toImage(settings.ssaa()); + } + + private TextureData[] texturesById() { + TextureData[] all = this.textures.snapshot(); + int max = -1; + for (TextureData texture : all) { + if (texture != null) { + max = Math.max(max, texture.id()); + } + } + TextureData[] byId = new TextureData[max + 1]; + for (TextureData texture : all) { + if (texture != null) { + byId[texture.id()] = texture; + } + } + return byId; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java new file mode 100644 index 0000000..5db0284 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java @@ -0,0 +1,71 @@ +package dev.loat.msmp_vision.render; + +import dev.loat.msmp_vision.raster.Framebuffer; +import dev.loat.msmp_vision.world.EnvironmentSnapshot; +import org.joml.Vector3f; + +import java.util.Arrays; + +/** + * Fills the framebuffer with a sky gradient, sun and moon. The horizon colour equals the fog colour so that + * distant terrain blends seamlessly into the sky. + */ +public final class SkyPainter { + + private static final int ZENITH_DAY = 0x78A7FF; + private static final float SUN_COS = 0.997f; + private static final float MOON_COS = 0.9985f; + + private SkyPainter() {} + + /** + * Paints the sky and clears depth. + * + * @param target The framebuffer. + * @param camera The camera. + * @param environment Environment values. + */ + public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment) { + int width = target.width(); + int height = target.height(); + int[] color = target.color(); + Arrays.fill(target.depth(), 0f); + + float day = Math.max(environment.skyLightFactor(), 0.03f); + float[] horizon = rgb(environment.fogColor()); + float[] zenith = {((ZENITH_DAY >> 16) & 0xFF) * day, ((ZENITH_DAY >> 8) & 0xFF) * day, (ZENITH_DAY & 0xFF) * day}; + Vector3f sun = new Vector3f((float) -Math.sin(environment.sunAngleRadians()), (float) Math.cos(environment.sunAngleRadians()), 0).normalize(); + + Vector3f dir = new Vector3f(); + for (int y = 0; y < height; y++) { + float ndcY = 1f - 2f * (y + 0.5f) / height; + for (int x = 0; x < width; x++) { + float ndcX = 2f * (x + 0.5f) / width - 1f; + dir.set(camera.forward()) + .fma(ndcX * camera.tanHalfFov() * camera.aspect(), camera.right()) + .fma(ndcY * camera.tanHalfFov(), camera.up()) + .normalize(); + float t = (float) Math.sqrt(Math.max(dir.y, 0f)); + float r = horizon[0] + (zenith[0] - horizon[0]) * t; + float g = horizon[1] + (zenith[1] - horizon[1]) * t; + float b = horizon[2] + (zenith[2] - horizon[2]) * t; + + float sunDot = dir.dot(sun); + if (sunDot > SUN_COS) { + r = 255; g = 235; b = 150; + } else if (-sunDot > MOON_COS) { + r = 225; g = 225; b = 232; + } + color[y * width + x] = 0xFF000000 | (clamp(r) << 16) | (clamp(g) << 8) | clamp(b); + } + } + } + + private static float[] rgb(int packed) { + return new float[]{(packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF}; + } + + private static int clamp(float v) { + return Math.max(0, Math.min(255, (int) v)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java new file mode 100644 index 0000000..1f11b27 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java @@ -0,0 +1,16 @@ +package dev.loat.msmp_vision.world; + +/** + * Time- and dimension-dependent values read from the level at capture time. + * + * @param sunAngleRadians Sun angle in radians (0 = zenith). + * @param skyLightFactor Sky light multiplier, 0 (night) to 1 (day). + * @param fogColor Fog colour as {@code 0xRRGGBB}. + * @param hasSkyLight Whether the dimension has sky light. + */ +public record EnvironmentSnapshot( + double sunAngleRadians, + float skyLightFactor, + int fogColor, + boolean hasSkyLight +) {} diff --git a/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java new file mode 100644 index 0000000..3723350 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/SectionSnapshot.java @@ -0,0 +1,113 @@ +package dev.loat.msmp_vision.world; + +import net.minecraft.core.Holder; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.DataLayer; + +/** + * Immutable copy of one 16x16x16 chunk section (blocks, biomes, light), safe to read from any thread. + */ +public final class SectionSnapshot { + + private final int sectionX; + private final int sectionY; + private final int sectionZ; + private final PalettedContainer states; + private final PalettedContainerRO> biomes; + private final DataLayer skyLight; + private final DataLayer blockLight; + + /** + * Creates a snapshot. + * + * @param sectionX Section X. + * @param sectionY Section Y. + * @param sectionZ Section Z. + * @param states Copied block container, or {@code null} for an all-air section. + * @param biomes Copied biome container. + * @param skyLight Copied sky light, or {@code null}. + * @param blockLight Copied block light, or {@code null}. + */ + public SectionSnapshot( + int sectionX, + int sectionY, + int sectionZ, + PalettedContainer states, + PalettedContainerRO> biomes, + DataLayer skyLight, + DataLayer blockLight + ) { + this.sectionX = sectionX; + this.sectionY = sectionY; + this.sectionZ = sectionZ; + this.states = states; + this.biomes = biomes; + this.skyLight = skyLight; + this.blockLight = blockLight; + } + + /** @return Section X. */ + public int sectionX() { return this.sectionX; } + + /** @return Section Y. */ + public int sectionY() { return this.sectionY; } + + /** @return Section Z. */ + public int sectionZ() { return this.sectionZ; } + + /** @return {@code true} if the section contains only air. */ + public boolean isEmpty() { return this.states == null; } + + /** + * Reads a block. + * + * @param x Local X (0-15). + * @param y Local Y (0-15). + * @param z Local Z (0-15). + * @return The block state. + */ + public BlockState block(int x, int y, int z) { + return this.states == null ? Blocks.AIR.defaultBlockState() : this.states.get(x, y, z); + } + + /** + * Reads a biome (4x4x4 resolution). + * + * @param x Local X (0-15). + * @param y Local Y (0-15). + * @param z Local Z (0-15). + * @return The biome holder. + */ + public Holder biome(int x, int y, int z) { + return this.biomes.get(x >> 2, y >> 2, z >> 2); + } + + /** + * Reads sky light. + * + * @param x Local X. + * @param y Local Y. + * @param z Local Z. + * @param fallback Value used when no light data exists. + * @return Light level 0-15. + */ + public int skyLight(int x, int y, int z, int fallback) { + return this.skyLight == null ? fallback : this.skyLight.get(x, y, z); + } + + /** + * Reads block light. + * + * @param x Local X. + * @param y Local Y. + * @param z Local Z. + * @return Light level 0-15. + */ + public int blockLight(int x, int y, int z) { + return this.blockLight == null ? 0 : this.blockLight.get(x, y, z); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java new file mode 100644 index 0000000..a108624 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java @@ -0,0 +1,89 @@ +package dev.loat.msmp_vision.world; + +import dev.loat.msmp_vision.render.Camera; +import dev.loat.msmp_vision.render.RenderSettings; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.world.attribute.EnvironmentAttributes; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3f; + +/** + * Copies the chunk sections around the camera into a {@link WorldSnapshot}. Must run on the server thread. + */ +public final class SnapshotCapture { + + private SnapshotCapture() {} + + /** + * Captures all loaded, potentially visible sections. + * + * @param level The level. + * @param camera The camera used for frustum culling. + * @param settings The render settings. + * @return The snapshot. + */ + public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings) { + Vec3 position = new Vec3(settings.position().get(0), settings.position().get(1), settings.position().get(2)); + Vector3f fog = new Vector3f(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position)).mul(255.0f); + EnvironmentSnapshot environment = new EnvironmentSnapshot( + level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD, + level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position), + (int) fog.x << 16 | (int) fog.y << 8 | (int) fog.z, + level.dimensionType().hasSkyLight() + ); + WorldSnapshot snapshot = new WorldSnapshot(environment); + + double radius = settings.renderDistance() + 16; + int minChunkX = Mth.floor(settings.position().get(0) - radius) >> 4; + int maxChunkX = Mth.floor(settings.position().get(0) + radius) >> 4; + int minChunkZ = Mth.floor(settings.position().get(2) - radius) >> 4; + int maxChunkZ = Mth.floor(settings.position().get(2) + radius) >> 4; + int minSectionY = level.getMinSectionY(); + LevelLightEngine light = level.getLightEngine(); + + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); + if (chunk == null) { + continue; + } + LevelChunkSection[] sections = chunk.getSections(); + for (int i = 0; i < sections.length; i++) { + int sy = minSectionY + i; + if (!isRelevant(camera, settings, radius, cx, sy, cz)) { + continue; + } + LevelChunkSection section = sections[i]; + SectionPos pos = SectionPos.of(cx, sy, cz); + snapshot.put(new SectionSnapshot( + cx, sy, cz, + section.hasOnlyAir() ? null : section.getStates().copy(), + section.getBiomes().copy(), + environment.hasSkyLight() ? copyLayer(light, LightLayer.SKY, pos) : null, + copyLayer(light, LightLayer.BLOCK, pos) + )); + } + } + } + return snapshot; + } + + private static boolean isRelevant(Camera camera, RenderSettings settings, double radius, int cx, int sy, int cz) { + double dx = (cx << 4) + 8 - settings.position().get(0); + double dy = (sy << 4) + 8 - settings.position().get(1); + double dz = (cz << 4) + 8 - settings.position().get(2); + return dx * dx + dy * dy + dz * dz <= radius * radius && camera.intersectsSection(cx, sy, cz); + } + + private static DataLayer copyLayer(LevelLightEngine engine, LightLayer layer, SectionPos pos) { + DataLayer data = engine.getLayerListener(layer).getDataLayerData(pos); + return data == null ? null : data.copy(); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java new file mode 100644 index 0000000..ca1d16a --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/WorldSnapshot.java @@ -0,0 +1,104 @@ +package dev.loat.msmp_vision.world; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.Holder; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Collection; + +/** + * A set of {@link SectionSnapshot}s plus environment data. Built on the server thread, afterwards read-only. + */ +public final class WorldSnapshot { + + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private final EnvironmentSnapshot environment; + private final Long2ObjectOpenHashMap sections = new Long2ObjectOpenHashMap<>(); + + /** + * Creates an empty snapshot. + * + * @param environment Environment values. + */ + public WorldSnapshot(EnvironmentSnapshot environment) { + this.environment = environment; + } + + /** + * Adds a section. Only call while building. + * + * @param section The section. + */ + public void put(SectionSnapshot section) { + this.sections.put(SectionPos.asLong(section.sectionX(), section.sectionY(), section.sectionZ()), section); + } + + /** @return Environment values. */ + public EnvironmentSnapshot environment() { return this.environment; } + + /** @return All captured sections. */ + public Collection sections() { return this.sections.values(); } + + /** + * Reads a block in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return The state, air if the position was not captured. + */ + public BlockState blockState(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? AIR : s.block(x & 15, y & 15, z & 15); + } + + /** + * Reads sky light in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Light 0-15. + */ + public int skyLight(int x, int y, int z) { + if (!this.environment.hasSkyLight()) { + return 0; + } + SectionSnapshot s = this.section(x, y, z); + return s == null ? 15 : s.skyLight(x & 15, y & 15, z & 15, 15); + } + + /** + * Reads block light in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Light 0-15. + */ + public int blockLight(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? 0 : s.blockLight(x & 15, y & 15, z & 15); + } + + /** + * Reads a biome in world coordinates. + * + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return The biome, or {@code null} if the position was not captured. + */ + public Holder biome(int x, int y, int z) { + SectionSnapshot s = this.section(x, y, z); + return s == null ? null : s.biome(x & 15, y & 15, z & 15); + } + + private SectionSnapshot section(int x, int y, int z) { + return this.sections.get(SectionPos.asLong(x >> 4, y >> 4, z >> 4)); + } +}