diff --git a/.github/ISSUE_TEMPLATE/.gitkeep b/.github/ISSUE_TEMPLATE/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml b/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml new file mode 100644 index 0000000..8b78f90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_method_or_parameter_suggestion.yml @@ -0,0 +1,94 @@ +name: 'New Method/Parameter Suggestion' +description: Suggest a new MSMP RPC method, a new request parameter or a rendering feature. +title: '[Suggestion] ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest an improvement! + + Please follow the existing naming and structure conventions where possible, ex.: + - `vision:foo` - the method, called by the client + - vectors are arrays like in Minecraft: `"position": [x, y, z]`, `"rotation": [yaw, pitch]` + - parameter names are `snake_case`, config keys are `kebab-case` + + You don't need to fill out every field perfectly - just give us as much detail as you can. + + - type: dropdown + id: kind + attributes: + label: 'What are you suggesting?' + options: + - 'New method (request/response)' + - 'New parameter for an existing method' + - 'Rendering feature (ex. entities, better lighting)' + - 'Not sure/something else' + + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: 'Use Case' + description: | + Why do you want this? What tooling, dashboard, or automation would this enable? + + validations: + required: true + + - type: textarea + id: description + attributes: + label: 'What should it do?' + description: | + Describe the functionality in detail. What data does it accept or return? + + validations: + required: true + + - type: textarea + id: example-payload + attributes: + label: 'Example request payload' + render: json + description: | + If you have an idea of the JSON shape, share it here. Not required, but it helps a lot. + + placeholder: | + { + "jsonrpc": "2.0", + "method": "vision:foo", + "params": [{ + "position": [0.5, 80.0, 0.5], + "rotation": [90.0, 0.0] + }], + "id": 1 + } + + validations: + required: false + + - type: dropdown + id: needs-config + attributes: + label: 'Would this need configurable settings?' + options: + - 'No, it should just work without configuration' + - 'Yes, it would need some configurable settings (defaults/limits)' + - 'Not sure' + + validations: + required: true + + - type: checkboxes + id: checks + attributes: + label: 'Checks' + options: + - label: "I searched existing issues and this hasn't been suggested yet" + required: true + + - label: 'This is a request for this mod specifically (not Minecraft itself or another mod)' + required: false diff --git a/README.md b/README.md index 5f99f33..66c3ce0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,186 @@ -# MSMP Vision Mod +# MSMP Vision + +A server-side Fabric mod that extends the [Minecraft Server Management Protocol](https://minecraft.wiki/w/Minecraft_Server_Management_Protocol) (MSMP) by providing a function for taking screenshots entirely on the server. No client and no GPU are required, the image is rendered by a multi-threaded software rasterizer. + +This mod is designed for tooling, dashboards, automation systems, external monitoring tools, and integrations that need a picture of the world without running a game client. + + +## Installation + +1. Download the mod `.jar` and place it in your server's `mods/` folder (requires [Fabric API](https://modrinth.com/mod/fabric-api)). +2. Enable the Management Server in `server.properties`: + ```properties + management-server-enabled=true + ``` +3. Start the server. The Management Server will listen on `localhost:25576` by default. + +On the first start the mod downloads the official client `.jar` of the running Minecraft version from Mojang and extracts the block models and textures to `/config/msmp/vision/client_assets/`. No Mojang assets are shipped with the mod. Until this is finished, screenshot requests fail with *"Client assets are still being prepared"*. + + +## Configuration + +On first start, the mod generates a configuration file at `/config/msmp/vision/config.yml`: + +```yaml +# Main configuration file for MSMP Vision. + +# Thread settings of the renderer. +threads: + # Number of render worker threads (meshing and rasterization). + # 0 = automatic (available processors - 1, at least 1). + # @default: 0 + render: 0 +# Values used when a request does not specify a setting. +defaults: + # Output width in pixels. + # @default: 256 + width: 256 + # Output height in pixels. + # @default: 256 + height: 256 + # Vertical field of view in degrees. + # @default: 70.0 + fov: 70.0 + # Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings. + # @default: 4 + render-distance-chunks: 4 + # Supersampling factor (1 = off). + # @default: 1 + ssaa: 1 + # Biome tint blend radius in blocks (0 = off). + # @default: 1 + biome-blend: 1 +# Upper limits that requests are validated against. Requests exceeding them are rejected. +limits: + # Maximum output width. + # @default: 1920 + max-width: 1920 + # Maximum output height. + # @default: 1080 + max-height: 1080 + # Maximum render distance in chunks (1 chunk = 16 blocks). + # @default: 32 + max-render-distance-chunks: 32 + # Maximum supersampling factor. + # @default: 3 + max-ssaa: 3 + # Maximum number of internally rendered pixels (width * height * ssaa^2). + # @default: 8000000 + max-rendered-pixels: 8000000 + # Minimum field of view in degrees. + # @default: 30.0 + min-fov: 30.0 + # Maximum field of view in degrees. + # @default: 110.0 + max-fov: 110.0 +# Tuning options of the rasterizer. +rendering: + # Edge length of a raster tile in pixels. One tile is one parallel work unit. + # @default: 64 + tile-size: 64 + # Fog starts at this fraction of the render distance. Minecraft itself only fogs the last ~10%. + # @default: 0.9 + fog-start-factor: 0.9 + # Smooth lighting and ambient occlusion like Minecraft's "Smooth Lighting" option: + # light is blended across each block face and corners are darkened. Disable for flat lighting, which meshes a bit faster. + # @default: true + smooth-lighting: true + # Skip back-facing triangles. Disable only for debugging. + # @default: true + backface-culling: true + # Deflate level of the PNG encoder, 0 (no compression) to 9 (smallest file). + # 1 encodes about 2.5x faster than 6 for a file that is roughly 30% larger. + # @default: 1 + png-compression-level: 1 +# Settings for chunks that are not loaded when a screenshot is requested. +chunks: + # How chunks that are not loaded are handled while a screenshot is taken. + # NONE: only chunks that are already loaded (ex. by a player) are rendered. + # LOAD: missing chunks are loaded from disk, chunks that do not exist yet are generated. + # @possible: NONE | LOAD + # @default: 'LOAD' + mode: LOAD + # Maximum number of chunks that may be loaded (or generated) for a single request. + # Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered. + # @default: 2048 + max-chunk-loads: 2048 + # Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time, + # keep it well below the server's watchdog (max-tick-time, 60s by default). Remaining chunks are rendered as empty space. + # @default: 10 + load-timeout-seconds: 10 +# Maximum time in seconds a single screenshot request may take (without chunk loading). +# @default: 30 +request-timeout-seconds: 30 +``` + +`defaults` are used for every optional parameter a request omits. Requests that exceed a value in `limits` are rejected instead of being clamped. Rendering cost grows with `width * height * ssaa^2` and with the square of the render distance (a render distance of 16 chunks can mean up to ~1000 chunks in view, so raise `chunks.max-chunk-loads` accordingly), so lower the limits on weak servers. + +**Chunk loading:** With `chunks.mode: LOAD` (default) the mod loads the chunks inside the camera's view on demand, so screenshots also work where no player is. Chunks that were never generated are generated, which can take a while and creates world data. Use `NONE` if screenshots must never change the world. MSMP calls run on the **server thread**: the server does not tick while a screenshot is being loaded and rendered, so keep `load-timeout-seconds` and `request-timeout-seconds` well below the watchdog (`max-tick-time`, 60 s by default). + + +## RPC Methods + +The mod currently provides the following MSMP RPC method. It is also automatically discoverable through the standard `rpc.discover` MSMP endpoint. + +| Method | Description | +| ------------------- | ------------------------------------------------------------------- | +| `vision:screenshot` | Renders a server-side screenshot and returns it as a Base64 PNG | + +### `vision:screenshot` + +Vectors are transferred as arrays, exactly like in the `entity` namespace. + +| Parameter | Type | Required | Description | +| ----------------- | ------------- | -------- | ---------------------------------------------------------------------------------- | +| `position` | `[x, y, z]` | yes | Camera position | +| `rotation` | `[yaw, pitch]`| no | Camera rotation in degrees (yaw `0` = south, `90` = west; pitch positive = down). Default `[0, 0]` | +| `dimension` | string | no | Dimension resource key. Default `minecraft:overworld` | +| `width` | int | no | Output width in pixels. Default from config | +| `height` | int | no | Output height in pixels. Default from config | +| `fov` | number | no | Vertical field of view in degrees. Default from config | +| `render_distance` | int | no | Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings. Default from config | +| `ssaa` | int | no | Supersampling factor, `1` = off. Default from config | +| `biome_blend` | int | no | Biome tint blend radius in blocks, `0` = off. Default from config | + +Example request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "vision:screenshot", + "params": [{ + "position": [128.5, 80.0, -32.3], + "rotation": [90.0, 0.0], + "width": 512, + "height": 288 + }] +} +``` + +Example response: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "image": "iVBORw0KGgoAAAANSUhEUg...", + "format": "png", + "width": 512, + "height": 288, + "render_time_ms": 412 + } +} +``` + +Chunks that are not loaded are loaded on demand (see `chunks` in the config). Whatever cannot be loaded within the limits shows up as empty space. + +> If you want more methods or parameters for other purposes, please [open an issue](https://github.com/MinecraftPlayground/msmp-vision-mod/issues/new?template=new_method_or_parameter_suggestion.yml) + + +## License + +[LGPL-3.0](LICENSE) diff --git a/gradle.properties b/gradle.properties index 45cbfe6..e833056 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ loom_version=1.16-SNAPSHOT fabric_api_version=0.145.1+26.1 # Mod Properties -mod_version=0.1.0 +mod_version=0.2.0 maven_group=dev.loat archives_base_name=msmp-vision-mod diff --git a/src/main/java/dev/loat/msmp_vision/VisionService.java b/src/main/java/dev/loat/msmp_vision/VisionService.java index 704ce97..9e121db 100644 --- a/src/main/java/dev/loat/msmp_vision/VisionService.java +++ b/src/main/java/dev/loat/msmp_vision/VisionService.java @@ -9,12 +9,13 @@ import dev.loat.msmp_vision.mesh.TintResolver; import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest; import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotResponse; +import dev.loat.msmp_vision.msmp.exceptions.UnknownDimensionException; import dev.loat.msmp_vision.render.Camera; import dev.loat.msmp_vision.render.RenderExecutor; import dev.loat.msmp_vision.render.RenderSettings; +import dev.loat.msmp_vision.render.Rendered; import dev.loat.msmp_vision.render.SceneRenderer; -import dev.loat.msmp_vision.world.SnapshotCapture; -import dev.loat.msmp_vision.world.WorldSnapshot; +import dev.loat.msmp_vision.world.SnapshotSession; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; @@ -23,8 +24,8 @@ import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; -import java.awt.image.BufferedImage; import java.nio.file.Path; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -52,7 +53,7 @@ public void start() { Path root = FabricLoader.getInstance().getConfigDir().resolve("msmp/vision/client_assets"); AssetManager assets = new AssetManager(root, FabricLoader.getInstance().getRawGameVersion()); TextureStore textures = new TextureStore(assets); - RenderExecutor executor = new RenderExecutor(config.threads.render); + RenderExecutor executor = new RenderExecutor(config.render.threads); SceneRenderer renderer = new SceneRenderer(assets, textures, executor); CompletableFuture ready = assets.initialize().thenRun(() -> TintResolver.initializeColorMaps(assets)); @@ -81,7 +82,8 @@ public void stop() { * @param server The server. * @param request The request. * @return The response containing a Base64 PNG. - * @throws IllegalArgumentException for invalid parameters. + * @throws dev.loat.msmp_vision.msmp.exceptions.InvalidParamsException for invalid parameters. + * @throws UnknownDimensionException if the requested dimension does not exist. * @throws IllegalStateException if the renderer is not ready or rendering failed. */ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest request) { @@ -102,21 +104,30 @@ public ScreenshotResponse screenshot(MinecraftServer server, ScreenshotRequest r long start = System.nanoTime(); Camera camera = new Camera(settings); - WorldSnapshot snapshot = this.capture(server, level, camera, settings, config.requestTimeoutSeconds); - BufferedImage image = current.renderer().render(snapshot, camera, settings, config); - String encoded = ImageEncoder.toBase64Png(image); - int elapsed = (int) ((System.nanoTime() - start) / 1_000_000L); + SnapshotSession session = this.capture(server, level, camera, settings, config); + Rendered rendered = current.renderer().render(session.snapshot(), camera, settings, config); + long encodeStart = System.nanoTime(); + String encoded = ImageEncoder.toBase64Png(rendered.image(), config.render.pngCompressionLevel); + long end = System.nanoTime(); - return new ScreenshotResponse(encoded, "png", image.getWidth(), image.getHeight(), elapsed); + Logger.info("Screenshot timings: capture %d ms (server thread), mesh %d ms, sky %d ms, raster %d ms, downsample %d ms, encode %d ms, total %d ms." + .formatted(session.workNanos() / 1_000_000L, rendered.meshNanos() / 1_000_000L, rendered.skyNanos() / 1_000_000L, + rendered.tileNanos() / 1_000_000L, rendered.downsampleNanos() / 1_000_000L, (end - encodeStart) / 1_000_000L, + (end - start) / 1_000_000L)); + return new ScreenshotResponse(encoded, "png", rendered.image().getWidth(), rendered.image().getHeight(), (int) ((end - start) / 1_000_000L)); } - private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, long timeoutSeconds) { + /** + * Captures the whole world at once on the server thread. MSMP handlers already run there, in which case the + * capture (including loading missing chunks) is done directly; otherwise it is scheduled on the server thread. + */ + private SnapshotSession capture(MinecraftServer server, ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { if (server.isSameThread()) { - return SnapshotCapture.capture(level, camera, settings); + return captureAll(level, camera, settings, config); } + long timeoutSeconds = config.requestTimeoutSeconds + config.chunks.loadTimeoutSeconds; try { - return CompletableFuture.supplyAsync(() -> SnapshotCapture.capture(level, camera, settings), server) - .get(timeoutSeconds, TimeUnit.SECONDS); + return CompletableFuture.supplyAsync(() -> captureAll(level, camera, settings, config), server).get(timeoutSeconds, TimeUnit.SECONDS); } catch (TimeoutException e) { throw new IllegalStateException("The server thread did not respond in time."); } catch (ExecutionException e) { @@ -127,14 +138,23 @@ private WorldSnapshot capture(MinecraftServer server, ServerLevel level, Camera } } - private ServerLevel resolveLevel(MinecraftServer server, String dimension) { - if (dimension == null || dimension.isBlank()) { + private static SnapshotSession captureAll(ServerLevel level, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { + SnapshotSession session = new SnapshotSession(level, camera, settings, config.chunks); + session.step(Long.MAX_VALUE); + return session; + } + + private ServerLevel resolveLevel(MinecraftServer server, Optional dimension) { + if (dimension.isEmpty()) { return server.overworld(); } - ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.parse(dimension)); - ServerLevel level = server.getLevel(key); + Identifier identifier = Identifier.tryParse(dimension.get()); + if (identifier == null) { + throw new UnknownDimensionException(dimension.get()); + } + ServerLevel level = server.getLevel(ResourceKey.create(Registries.DIMENSION, identifier)); if (level == null) { - throw new IllegalArgumentException("Unknown dimension: " + dimension); + throw new UnknownDimensionException(dimension.get()); } return level; } diff --git a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java index c9c5d92..094bcf3 100644 --- a/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java +++ b/src/main/java/dev/loat/msmp_vision/config/files/MSMPVisionConfigFile.java @@ -1,10 +1,10 @@ package dev.loat.msmp_vision.config.files; import dev.loat.config_lib.annotation.Annotation; +import dev.loat.msmp_vision.config.files.chunks.ChunksConfig; import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig; import dev.loat.msmp_vision.config.files.limits.LimitsConfig; -import dev.loat.msmp_vision.config.files.rendering.RenderingConfig; -import dev.loat.msmp_vision.config.files.threads.ThreadsConfig; +import dev.loat.msmp_vision.config.files.render.RenderConfig; @Annotation.Comment(""" @@ -14,18 +14,19 @@ public class MSMPVisionConfigFile { private MSMPVisionConfigFile() {} - @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.") + @Annotation.Comment("Upper limits that requests are validated against. Requests exceeding them are rejected.") public LimitsConfig limits = new LimitsConfig(); @Annotation.Comment("Tuning options of the rasterizer.") - public RenderingConfig rendering = new RenderingConfig(); - - @Annotation.Comment("Maximum time in seconds a single screenshot request may take.") + public RenderConfig render = new RenderConfig(); + + @Annotation.Comment("Settings for chunks that are not loaded when a screenshot is requested.") + public ChunksConfig chunks = new ChunksConfig(); + + @Annotation.Comment("Maximum time in seconds a single screenshot request may take (without chunk loading).") + @Annotation.Key("request-timeout-seconds") public int requestTimeoutSeconds = 30; } diff --git a/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java new file mode 100644 index 0000000..5265836 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/chunks/ChunksConfig.java @@ -0,0 +1,33 @@ +package dev.loat.msmp_vision.config.files.chunks; + +import dev.loat.config_lib.annotation.Annotation; + + +public class ChunksConfig { + + @Annotation.Comment(""" + How chunks that are not loaded are handled while a screenshot is taken. + NONE: only chunks that are already loaded (ex. by a player) are rendered. + LOAD: missing chunks are loaded from disk, chunks that do not exist yet are generated. + """) + public Mode mode = Mode.LOAD; + + @Annotation.Comment(""" + Maximum number of chunks that may be loaded (or generated) for a single request. + Chunks beyond this limit are rendered as empty space. Only chunks inside the camera's view are considered. + """) + @Annotation.Key("max-chunk-loads") + public int maxChunkLoads = 2048; + + @Annotation.Comment(""" + Maximum time in seconds spent on loading chunks for a single request. The server thread is blocked during this time, + keep it well below the server's watchdog (max-tick-time, 60s by default). Remaining chunks are rendered as empty space. + """) + @Annotation.Key("load-timeout-seconds") + public int loadTimeoutSeconds = 10; + + public static enum Mode { + NONE, + LOAD + } +} diff --git a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java index b1a5877..3d432b7 100644 --- a/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java +++ b/src/main/java/dev/loat/msmp_vision/config/files/defaults/DefaultsConfig.java @@ -4,6 +4,7 @@ public class DefaultsConfig { + @Annotation.Comment("Output width in pixels.") public int width = 256; @@ -13,12 +14,14 @@ public class DefaultsConfig { @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("Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings.") + @Annotation.Key("render-distance-chunks") + public int renderDistanceChunks = 4; @Annotation.Comment("Supersampling factor (1 = off).") public int ssaa = 1; @Annotation.Comment("Biome tint blend radius in blocks (0 = off).") + @Annotation.Key("biome-blend") public int biomeBlend = 1; } diff --git a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java index 7dd0f9f..f9d83c2 100644 --- a/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java +++ b/src/main/java/dev/loat/msmp_vision/config/files/limits/LimitsConfig.java @@ -4,24 +4,32 @@ public class LimitsConfig { + @Annotation.Comment("Maximum output width.") + @Annotation.Key("max-width") public int maxWidth = 1920; @Annotation.Comment("Maximum output height.") + @Annotation.Key("max-height") public int maxHeight = 1080; - @Annotation.Comment("Maximum render distance in blocks.") - public int maxRenderDistance = 128; + @Annotation.Comment("Maximum render distance in chunks (1 chunk = 16 blocks).") + @Annotation.Key("max-render-distance-chunks") + public int maxRenderDistanceChunks = 32; @Annotation.Comment("Maximum supersampling factor.") + @Annotation.Key("max-ssaa") public int maxSsaa = 3; @Annotation.Comment("Maximum number of internally rendered pixels (width * height * ssaa^2).") + @Annotation.Key("max-rendered-pixels") public int maxRenderedPixels = 8_000_000; @Annotation.Comment("Minimum field of view in degrees.") + @Annotation.Key("min-fov") public double minFov = 30.0; @Annotation.Comment("Maximum field of view in degrees.") + @Annotation.Key("max-fov") public double maxFov = 110.0; } diff --git a/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java new file mode 100644 index 0000000..2a493f0 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/config/files/render/RenderConfig.java @@ -0,0 +1,39 @@ +package dev.loat.msmp_vision.config.files.render; + +import dev.loat.config_lib.annotation.Annotation; + + +public class RenderConfig { + + @Annotation.Comment(""" + Number of render worker threads (meshing and rasterization). + 0 = automatic (available processors - 1, at least 1). + """) + public int threads = 0; + + @Annotation.Comment("Edge length of a raster tile in pixels. One tile is one parallel work unit.") + @Annotation.Key("tile-size") + public int tileSize = 64; + + @Annotation.Comment("Fog starts at this fraction of the render distance. Minecraft itself only fogs the last ~10%.") + @Annotation.Key("fog-start-factor") + public double fogStartFactor = 0.9; + + @Annotation.Comment(""" + Smooth lighting and ambient occlusion like Minecraft's \"Smooth Lighting\" option: + light is blended across each block face and corners are darkened. Disable for flat lighting, which meshes a bit faster. + """) + @Annotation.Key("smooth-lighting") + public boolean smoothLighting = true; + + @Annotation.Comment("Skip back-facing triangles. Disable only for debugging.") + @Annotation.Key("backface-culling") + public boolean backfaceCulling = true; + + @Annotation.Comment(""" + Deflate level of the PNG encoder, 0 (no compression) to 9 (smallest file). + 1 encodes about 2.5x faster than 6 for a file that is roughly 30% larger. + """) + @Annotation.Key("png-compression-level") + public int pngCompressionLevel = 1; +} diff --git a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java index 59aacf2..655c919 100644 --- a/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java +++ b/src/main/java/dev/loat/msmp_vision/encode/ImageEncoder.java @@ -1,34 +1,120 @@ package dev.loat.msmp_vision.encode; -import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.io.UncheckedIOException; import java.util.Base64; +import java.util.zip.CRC32; +import java.util.zip.Deflater; /** * Encodes rendered images for transport. + * + *

Uses a minimal PNG writer (8-bit RGB, no row filtering) with a configurable deflate level instead of + * {@code ImageIO}, which is roughly twice as slow at full HD for a file that is only slightly smaller.

*/ public final class ImageEncoder { + private static final byte[] SIGNATURE = {(byte) 137, 80, 78, 71, 13, 10, 26, 10}; + private ImageEncoder() {} /** - * Encodes an image as Base64 PNG. + * Encodes an image as Base64 PNG with the fastest compression. * * @param image The image. * @return Base64 string without prefix. */ public static String toBase64Png(BufferedImage image) { + return toBase64Png(image, Deflater.BEST_SPEED); + } + + /** + * Encodes an image as Base64 PNG. + * + * @param image The image. + * @param compressionLevel Deflate level, 0 (store) to 9 (smallest, slowest). Values outside are clamped. + * @return Base64 string without prefix. + */ + public static String toBase64Png(BufferedImage image, int compressionLevel) { try { - ByteArrayOutputStream out = new ByteArrayOutputStream(image.getWidth() * image.getHeight()); - if (!ImageIO.write(image, "png", out)) { - throw new IOException("No PNG writer available"); - } - return Base64.getEncoder().encodeToString(out.toByteArray()); + return Base64.getEncoder().encodeToString(toPng(image, Math.max(0, Math.min(9, compressionLevel)))); } catch (IOException e) { throw new UncheckedIOException(e); } } + + /** + * Encodes an image as PNG bytes. + * + * @param image The image. + * @param level Deflate level, 0 to 9. + * @return The PNG file. + * @throws IOException never in practice, all streams are in memory. + */ + static byte[] toPng(BufferedImage image, int level) throws IOException { + int width = image.getWidth(); + int height = image.getHeight(); + int[] pixels = image.getType() == BufferedImage.TYPE_INT_RGB || image.getType() == BufferedImage.TYPE_INT_ARGB + ? ((DataBufferInt) image.getRaster().getDataBuffer()).getData() + : image.getRGB(0, 0, width, height, null, 0, width); + + byte[] raw = new byte[height * (1 + width * 3)]; + int o = 0; + for (int y = 0; y < height; y++) { + raw[o++] = 0; // filter type "none" + int row = y * width; + for (int x = 0; x < width; x++) { + int pixel = pixels[row + x]; + raw[o++] = (byte) (pixel >> 16); + raw[o++] = (byte) (pixel >> 8); + raw[o++] = (byte) pixel; + } + } + + Deflater deflater = new Deflater(level); + ByteArrayOutputStream compressed = new ByteArrayOutputStream(raw.length / 3); + try { + deflater.setInput(raw); + deflater.finish(); + byte[] buffer = new byte[1 << 16]; + while (!deflater.finished()) { + int n = deflater.deflate(buffer); + compressed.write(buffer, 0, n); + } + } finally { + deflater.close(); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(compressed.size() + 64); + out.write(SIGNATURE); + ByteArrayOutputStream header = new ByteArrayOutputStream(13); + DataOutputStream headerData = new DataOutputStream(header); + headerData.writeInt(width); + headerData.writeInt(height); + headerData.writeByte(8); // bit depth + headerData.writeByte(2); // colour type: RGB + headerData.writeByte(0); // compression + headerData.writeByte(0); // filter + headerData.writeByte(0); // interlace + writeChunk(out, "IHDR", header.toByteArray()); + writeChunk(out, "IDAT", compressed.toByteArray()); + writeChunk(out, "IEND", new byte[0]); + return out.toByteArray(); + } + + private static void writeChunk(ByteArrayOutputStream out, String type, byte[] data) throws IOException { + DataOutputStream stream = new DataOutputStream(out); + byte[] name = type.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + stream.writeInt(data.length); + stream.write(name); + stream.write(data); + CRC32 crc = new CRC32(); + crc.update(name); + crc.update(data); + stream.writeInt((int) crc.getValue()); + } } diff --git a/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java new file mode 100644 index 0000000..d13f39d --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/logging/RPCConnectionLogger.java @@ -0,0 +1,25 @@ +package dev.loat.msmp_vision.logging; + +/** + * Logger that prefixes every message with the ID of the RPC connection it belongs to. + */ +public class RPCConnectionLogger { + + private RPCConnectionLogger() {} + + public static void debug(Integer connectionId, String message) { + Logger.debug("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void info(Integer connectionId, String message) { + Logger.info("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void warning(Integer connectionId, String message) { + Logger.warning("RPC Connection #%s: %s".formatted(connectionId, message)); + } + + public static void error(Integer connectionId, String message) { + Logger.error("RPC Connection #%s: %s".formatted(connectionId, message)); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java index 8990a0e..41cfd30 100644 --- a/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java +++ b/src/main/java/dev/loat/msmp_vision/mesh/QuadBuffer.java @@ -5,17 +5,18 @@ /** * Growable struct-of-arrays storage for lit, tinted quads in camera-relative coordinates. + * Every quad has one colour per vertex, so smooth lighting and ambient occlusion can vary across a face. */ public final class QuadBuffer { private float[] positions = new float[12 * 64]; private float[] uvs = new float[8 * 64]; - private int[] colors = new int[64]; + private int[] colors = new int[4 * 64]; private int[] textures = new int[64]; private int count; /** - * Appends a quad. + * Appends a quad with the same colour on all four vertices. * * @param pos 12 floats, block-local vertex positions. * @param offsetX X offset added to every vertex. @@ -25,32 +26,55 @@ public final class QuadBuffer { * @param texture Texture id. * @param rgb Vertex colour multiplier as {@code 0xRRGGBB}. */ - public void add( - float[] pos, - float offsetX, - float offsetY, - float offsetZ, - float[] uv, - int texture, - int rgb - ) { + public void add(float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int rgb) { + int slot = this.reserve(); + this.write(slot, pos, offsetX, offsetY, offsetZ, uv, texture, 0); + Arrays.fill(this.colors, slot * 4, slot * 4 + 4, rgb); + } + + /** + * Appends a quad with one colour per vertex. + * + * @param pos 12 floats, block-local vertex positions. + * @param offsetX X offset added to every vertex. + * @param offsetY Y offset added to every vertex. + * @param offsetZ Z offset added to every vertex. + * @param uv 8 floats of texture coordinates. + * @param texture Texture id. + * @param rgb 4 vertex colour multipliers as {@code 0xRRGGBB}, in the order of {@code pos}. + * @param rotate Number of vertices the quad is rotated by (0 or 1), which flips the diagonal it is split along. + */ + public void add(float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int[] rgb, int rotate) { + int slot = this.reserve(); + this.write(slot, pos, offsetX, offsetY, offsetZ, uv, texture, rotate); + for (int v = 0; v < 4; v++) { + this.colors[slot * 4 + v] = rgb[(v + rotate) & 3]; + } + } + + private int reserve() { if (this.count == this.textures.length) { int capacity = this.count * 2; this.positions = Arrays.copyOf(this.positions, capacity * 12); this.uvs = Arrays.copyOf(this.uvs, capacity * 8); - this.colors = Arrays.copyOf(this.colors, capacity); + this.colors = Arrays.copyOf(this.colors, capacity * 4); this.textures = Arrays.copyOf(this.textures, capacity); } - int p = this.count * 12; + return this.count++; + } + + private void write(int slot, float[] pos, float offsetX, float offsetY, float offsetZ, float[] uv, int texture, int rotate) { + int p = slot * 12; + int t = slot * 8; for (int v = 0; v < 4; v++) { - this.positions[p + v * 3] = pos[v * 3] + offsetX; - this.positions[p + v * 3 + 1] = pos[v * 3 + 1] + offsetY; - this.positions[p + v * 3 + 2] = pos[v * 3 + 2] + offsetZ; + int s = (v + rotate) & 3; + this.positions[p + v * 3] = pos[s * 3] + offsetX; + this.positions[p + v * 3 + 1] = pos[s * 3 + 1] + offsetY; + this.positions[p + v * 3 + 2] = pos[s * 3 + 2] + offsetZ; + this.uvs[t + v * 2] = uv[s * 2]; + this.uvs[t + v * 2 + 1] = uv[s * 2 + 1]; } - System.arraycopy(uv, 0, this.uvs, this.count * 8, 8); - this.colors[this.count] = rgb; - this.textures[this.count] = texture; - this.count++; + this.textures[slot] = texture; } /** @return Number of quads. */ @@ -64,9 +88,10 @@ public void add( /** * @param quad Quad index. + * @param vertex Vertex index (0-3). * @return Colour multiplier as {@code 0xRRGGBB}. */ - public int color(int quad) { return this.colors[quad]; } + public int color(int quad, int vertex) { return this.colors[quad * 4 + vertex]; } /** * @param quad Quad index. diff --git a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java index c4a3378..18cd56e 100644 --- a/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java +++ b/src/main/java/dev/loat/msmp_vision/mesh/SectionMesher.java @@ -10,13 +10,16 @@ import dev.loat.msmp_vision.world.SectionSnapshot; import dev.loat.msmp_vision.world.WorldSnapshot; import net.minecraft.core.Direction; +import net.minecraft.tags.BlockTags; import net.minecraft.tags.FluidTags; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.material.FluidState; /** - * Builds the {@link SectionGeometry} of one section: block quads with flat lighting, face shading and biome tint, - * plus simple fluid surfaces. Vertex colours are fully precomputed so the fragment stage only multiplies. + * Builds the {@link SectionGeometry} of one section: block quads with smooth lighting and ambient occlusion + * (see {@link VertexLighting}), face shading and biome tint, plus simple fluid surfaces. Quads that do not lie on a + * block face (plants, slabs) and all quads with smooth lighting disabled get flat lighting. Vertex colours are fully + * precomputed so the fragment stage only multiplies. * *

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

*/ @@ -25,6 +28,11 @@ public final class SectionMesher { /** Directional shading per {@link Direction#ordinal()}: DOWN, UP, NORTH, SOUTH, WEST, EAST. */ private static final float[] FACE_SHADE = {0.5f, 1.0f, 0.8f, 0.8f, 0.6f, 0.6f}; private static final float MIN_LIGHT = 0.04f; + /** + * Debug aid, enabled with the JVM flag {@code -Dmsmp_vision.debug.sections=true}: tints chunk columns in an + * alternating red/blue checkerboard so that chunk borders and missing columns become visible in the screenshot. + */ + private static final boolean DEBUG_SECTIONS = Boolean.getBoolean("msmp_vision.debug.sections"); private static final float[] FLUID_UV_TOP = {0, 0, 0, 1, 1, 1, 1, 0}; private final WorldSnapshot world; @@ -38,6 +46,11 @@ public final class SectionMesher { private final int blend; private final float[] scratch = new float[12]; private final float[] sideUv = new float[8]; + private final boolean smoothLighting; + private final VertexLighting lighting; + private final float[] brightness = new float[4]; + private final int[] vertexColors = new int[4]; + private int debugTint = 0xFFFFFF; /** * Creates a mesher. @@ -48,8 +61,9 @@ public final class SectionMesher { * @param tint Tint resolver. * @param camera Camera providing the coordinate origin. * @param biomeBlend Biome blend radius. + * @param smoothLighting Whether to blend light and darken corners (ambient occlusion) per vertex. */ - public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore textures, TintResolver tint, Camera camera, int biomeBlend) { + public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore textures, TintResolver tint, Camera camera, int biomeBlend, boolean smoothLighting) { this.world = world; this.environment = world.environment(); this.baker = baker; @@ -59,6 +73,8 @@ public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore te this.originY = camera.originY(); this.originZ = camera.originZ(); this.blend = biomeBlend; + this.smoothLighting = smoothLighting; + this.lighting = new VertexLighting(new WorldSamples(world), this.environment.skyLightFactor(), MIN_LIGHT); } /** @@ -70,6 +86,9 @@ public SectionMesher(WorldSnapshot world, BlockModelBaker baker, TextureStore te public SectionGeometry mesh(SectionSnapshot section) { QuadBuffer opaque = new QuadBuffer(); QuadBuffer translucent = new QuadBuffer(); + if (DEBUG_SECTIONS) { + this.debugTint = ((section.sectionX() + section.sectionZ()) & 1) == 0 ? 0xFFA0A0 : 0xA0A0FF; + } int baseX = section.sectionX() << 4; int baseY = section.sectionY() << 4; int baseZ = section.sectionZ() << 4; @@ -115,11 +134,25 @@ private void meshBlock(BlockState state, boolean solid, int x, int y, int z, Qua continue; } Direction face = quad.face(); - float light = this.light(x, y, z, face, solid); float shade = quad.shade() ? FACE_SHADE[face.ordinal()] : 1.0f; - int tintRgb = quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF; - int color = pack(shade * light, tintRgb); - (quad.translucent() ? translucent : opaque).add(quad.positions(), x - this.originX, y - this.originY, z - this.originZ, quad.uvs(), quad.texture(), color); + int tintRgb = multiply(quad.tintIndex() >= 0 ? this.tint.blockTint(this.world, state, x, y, z, this.blend) : 0xFFFFFF, this.debugTint); + QuadBuffer target = quad.translucent() ? translucent : opaque; + float offsetX = x - this.originX; + float offsetY = y - this.originY; + float offsetZ = z - this.originZ; + + int axis = face.getAxis().ordinal(); + int sign = face.getStepX() + face.getStepY() + face.getStepZ(); + if (this.smoothLighting && VertexLighting.isFullFace(quad.positions(), axis, sign)) { + int rotate = this.lighting.compute(x, y, z, axis, sign, quad.positions(), shade, this.brightness); + for (int i = 0; i < 4; i++) { + this.vertexColors[i] = pack(this.brightness[i], tintRgb); + } + target.add(quad.positions(), offsetX, offsetY, offsetZ, quad.uvs(), quad.texture(), this.vertexColors, rotate); + } else { + float light = this.light(x, y, z, face, solid); + target.add(quad.positions(), offsetX, offsetY, offsetZ, quad.uvs(), quad.texture(), pack(shade * light, tintRgb)); + } } } @@ -128,7 +161,7 @@ private void meshFluid(FluidState fluid, int x, int y, int z, QuadBuffer opaque, TextureData still = this.textures.get(water ? "minecraft:block/water_still" : "minecraft:block/lava_still"); TextureData flow = this.textures.get(water ? "minecraft:block/water_flow" : "minecraft:block/lava_flow"); QuadBuffer target = water ? translucent : opaque; - int tintRgb = water ? this.tint.waterTint(this.world, x, y, z, this.blend) : 0xFFFFFF; + int tintRgb = multiply(water ? this.tint.waterTint(this.world, x, y, z, this.blend) : 0xFFFFFF, this.debugTint); float light = water ? this.light(x, y, z, Direction.UP, false) : 1.0f; FluidState above = this.world.blockState(x, y + 1, z).getFluidState(); @@ -170,6 +203,58 @@ private float light(int x, int y, int z, Direction face, boolean solid) { return Math.max(level / (4.0f - 3.0f * level), MIN_LIGHT); } + /** Adapts the captured world to the Minecraft-free {@link VertexLighting}; remembers the last looked-up block. */ + private static final class WorldSamples implements VertexLighting.Samples { + + private final WorldSnapshot world; + private int lastX; + private int lastY; + private int lastZ; + private BlockState last; + + WorldSamples(WorldSnapshot world) { + this.world = world; + } + + private BlockState state(int x, int y, int z) { + if (this.last == null || x != this.lastX || y != this.lastY || z != this.lastZ) { + this.last = this.world.blockState(x, y, z); + this.lastX = x; + this.lastY = y; + this.lastZ = z; + } + return this.last; + } + + @Override + public boolean opaque(int x, int y, int z) { + return this.state(x, y, z).isSolidRender(); + } + + @Override + public boolean occluder(int x, int y, int z) { + BlockState state = this.state(x, y, z); + return state.isSolidRender() || state.is(BlockTags.LEAVES); + } + + @Override + public int sky(int x, int y, int z) { + return this.world.skyLight(x, y, z); + } + + @Override + public int block(int x, int y, int z) { + return this.world.blockLight(x, y, z); + } + } + + private static int multiply(int a, int b) { + int r = ((a >> 16) & 0xFF) * ((b >> 16) & 0xFF) / 255; + int g = ((a >> 8) & 0xFF) * ((b >> 8) & 0xFF) / 255; + int bl = (a & 0xFF) * (b & 0xFF) / 255; + return (r << 16) | (g << 8) | bl; + } + private static int pack(float brightness, int tintRgb) { int r = clamp(brightness * ((tintRgb >> 16) & 0xFF)); int g = clamp(brightness * ((tintRgb >> 8) & 0xFF)); diff --git a/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java b/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java new file mode 100644 index 0000000..e397949 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/mesh/VertexLighting.java @@ -0,0 +1,179 @@ +package dev.loat.msmp_vision.mesh; + +/** + * Smooth lighting and ambient occlusion of a quad, following Minecraft's "smooth lighting" algorithm. + * + *

For every vertex of a face, the four blocks around that vertex in the plane of the face are sampled + * (the block in front of the face and its two edge neighbours plus the diagonal one). Their light is averaged, + * and their ambient occlusion shade (0.2 for blocks that fill the whole cube, 1.0 otherwise) is averaged as well. + * If both edge neighbours are opaque, the diagonal block is hidden and is replaced by an edge neighbour, which + * prevents light from leaking through corners. Samples inside opaque blocks use the light of the centre sample + * instead of their own (zero) light, so only the occlusion shade darkens a corner.

+ * + *

This class does not depend on Minecraft types. Instances hold scratch buffers and must not be shared + * between threads.

+ */ +public final class VertexLighting { + + /** Shade of a block that fills its whole cube, compared to 1.0 for everything else. */ + public static final float OCCLUDED_SHADE = 0.2f; + + /** Access to the surrounding world. */ + public interface Samples { + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return {@code true} if the block hides what is behind it (used for corner handling and light sampling). + */ + boolean opaque(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return {@code true} if the block darkens neighbouring faces (ambient occlusion). + */ + boolean occluder(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Sky light 0-15. + */ + int sky(int x, int y, int z); + + /** + * @param x World X. + * @param y World Y. + * @param z World Z. + * @return Block light 0-15. + */ + int block(int x, int y, int z); + } + + private final Samples samples; + private final float skyLightFactor; + private final float minLight; + private final int[] sideU = new int[3]; + private final int[] sideV = new int[3]; + private final int[] corner = new int[3]; + + /** + * Creates a calculator. + * + * @param samples The world. + * @param skyLightFactor Sky light multiplier, 0 (night) to 1 (day). + * @param minLight Brightness floor, so that fully dark places do not become pitch black. + */ + public VertexLighting(Samples samples, float skyLightFactor, float minLight) { + this.samples = samples; + this.skyLightFactor = skyLightFactor; + this.minLight = minLight; + } + + /** + * Tells whether a quad lies in the plane of the block face in direction {@code axis}/{@code sign}. + * Only such quads are lit from the neighbouring block; others (plants, slabs) use flat lighting. + * + * @param pos 4 vertices x 3 floats, block-local. + * @param axis Axis of the face normal (0 = X, 1 = Y, 2 = Z). + * @param sign Direction along the axis (+1 or -1). + * @return {@code true} if all four vertices lie on that face of the block. + */ + public static boolean isFullFace(float[] pos, int axis, int sign) { + float expected = sign > 0 ? 1f : 0f; + for (int v = 0; v < 4; v++) { + if (Math.abs(pos[v * 3 + axis] - expected) > 1e-3f) { + return false; + } + } + return true; + } + + /** + * Computes the brightness of all four vertices of a face. + * + * @param x Block X. + * @param y Block Y. + * @param z Block Z. + * @param axis Axis of the face normal (0 = X, 1 = Y, 2 = Z). + * @param sign Direction along the axis (+1 or -1). + * @param pos 4 vertices x 3 floats, block-local. + * @param directionalShade Constant per-face shade (1.0 up, 0.5 down, ...), 1.0 if the quad is unshaded. + * @param out Receives 4 brightness values (0..1), without biome tint. + * @return {@code 1} if the quad should be rotated by one vertex so that its diagonal follows the darker + * vertex pair (avoids visible artefacts), otherwise {@code 0}. + */ + public int compute(int x, int y, int z, int axis, int sign, float[] pos, float directionalShade, float[] out) { + int bx = axis == 0 ? x + sign : x; + int by = axis == 1 ? y + sign : y; + int bz = axis == 2 ? z + sign : z; + int u = axis == 0 ? 1 : 0; + int v = axis == 2 ? 1 : 2; + + float centerShade = this.samples.occluder(bx, by, bz) ? OCCLUDED_SHADE : 1f; + int centerSky = this.samples.sky(bx, by, bz); + int centerBlock = this.samples.block(bx, by, bz); + + for (int i = 0; i < 4; i++) { + int su = pos[i * 3 + u] >= 0.5f ? 1 : -1; + int sv = pos[i * 3 + v] >= 0.5f ? 1 : -1; + + this.sideU[0] = bx; this.sideU[1] = by; this.sideU[2] = bz; + this.sideU[u] += su; + this.sideV[0] = bx; this.sideV[1] = by; this.sideV[2] = bz; + this.sideV[v] += sv; + this.corner[0] = this.sideU[0]; this.corner[1] = this.sideU[1]; this.corner[2] = this.sideU[2]; + this.corner[v] += sv; + + boolean opaqueU = this.samples.opaque(this.sideU[0], this.sideU[1], this.sideU[2]); + boolean opaqueV = this.samples.opaque(this.sideV[0], this.sideV[1], this.sideV[2]); + + float shadeU = this.shade(this.sideU); + float shadeV = this.shade(this.sideV); + float skyU = opaqueU ? centerSky : this.samples.sky(this.sideU[0], this.sideU[1], this.sideU[2]); + float skyV = opaqueV ? centerSky : this.samples.sky(this.sideV[0], this.sideV[1], this.sideV[2]); + float blockU = opaqueU ? centerBlock : this.samples.block(this.sideU[0], this.sideU[1], this.sideU[2]); + float blockV = opaqueV ? centerBlock : this.samples.block(this.sideV[0], this.sideV[1], this.sideV[2]); + + float shadeC; + float skyC; + float blockC; + if (opaqueU && opaqueV) { + shadeC = shadeU; + skyC = skyU; + blockC = blockU; + } else { + boolean opaqueC = this.samples.opaque(this.corner[0], this.corner[1], this.corner[2]); + shadeC = this.shade(this.corner); + skyC = opaqueC ? centerSky : this.samples.sky(this.corner[0], this.corner[1], this.corner[2]); + blockC = opaqueC ? centerBlock : this.samples.block(this.corner[0], this.corner[1], this.corner[2]); + } + + float shade = (centerShade + shadeU + shadeV + shadeC) * 0.25f; + float sky = (centerSky + skyU + skyV + skyC) * 0.25f; + float block = (centerBlock + blockU + blockV + blockC) * 0.25f; + out[i] = directionalShade * shade * this.brightness(sky, block); + } + return out[0] + out[2] > out[1] + out[3] ? 1 : 0; + } + + /** + * Brightness for a light level, using Minecraft's light curve. + * + * @param sky Sky light 0-15. + * @param block Block light 0-15. + * @return Brightness 0..1, at least the configured floor. + */ + public float brightness(float sky, float block) { + float level = Math.max(sky / 15f * this.skyLightFactor, block / 15f); + return Math.max(level / (4f - 3f * level), this.minLight); + } + + private float shade(int[] p) { + return this.samples.occluder(p[0], p[1], p[2]) ? OCCLUDED_SHADE : 1f; + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java index 0f46fe1..f0b732a 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/Endpoints.java @@ -8,6 +8,11 @@ import java.util.function.Supplier; +/** + * Central registration point for all {@code vision} MSMP endpoints. + * + *

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

+ */ public class Endpoints { private Endpoints() {} @@ -15,10 +20,11 @@ private Endpoints() {} * Registers all endpoints on the given {@link MSMPNamespace}. * * @param namespace The namespace to register all endpoints under - * @param msmpServer A supplier for the MSMPServer instance, used by some endpoints to subscribe to server events + * @param msmpServer A supplier for the MSMPServer instance, used by endpoints that push notifications + * @param service The service that renders the screenshots */ public static void register(MSMPNamespace namespace, Supplier msmpServer, VisionService service) { - + Screenshot.register(namespace, service); } } diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java index a830969..7f3f520 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/Screenshot.java @@ -1,25 +1,83 @@ package dev.loat.msmp_vision.msmp.endpoints.screenshot; +import java.util.Locale; + import dev.loat.msmp.MSMPNamespace; import dev.loat.msmp_vision.VisionService; +import dev.loat.msmp_vision.logging.RPCConnectionLogger; +/** + * Registers the {@code vision:screenshot} MSMP method. + * + *

Renders a server-side screenshot from the given position and rotation and returns it + * as a Base64 encoded PNG. No client is involved, the image is rendered entirely on the server.

+ * + *

Example request:

+ *

+ * {
+ *   "jsonrpc": "2.0",
+ *   "id": 1,
+ *   "method": "vision:screenshot",
+ *   "params": [{
+ *     "position": [128.5, 80.0, -32.3],
+ *     "rotation": [90.0, 0.0],
+ *     "width": 512,
+ *     "height": 288
+ *   }]
+ * }
+ * 
+ * + *

Example response:

+ *

+ * {
+ *   "image": "iVBORw0KGgoAAAANSUhEUg...",
+ *   "format": "png",
+ *   "width": 512,
+ *   "height": 288,
+ *   "render_time_ms": 412
+ * }
+ * 
+ */ public class Screenshot { private Screenshot() {} /** - * Registers the methods. Must be called during mod initialization, before the registry freezes. + * Registers the {@code vision:screenshot} method on the given {@link MSMPNamespace}. + * + *

Validation and rendering are delegated to {@link VisionService}. Values that are not + * part of the request are taken from the {@code defaults} section of the config, values + * exceeding the {@code limits} section are rejected.

* - * @param namespace The MSMP namespace to register under. - * @param service The service handling the requests. + * @param namespace The namespace to register this method under + * @param service The service handling the requests */ public static void register(MSMPNamespace namespace, VisionService service) { namespace.method("screenshot") - .description("Renders a server-side screenshot and returns it as a Base64 encoded PNG.") + .description("Renders a server-side screenshot and returns it as a Base64 encoded PNG") .requestSchema(ScreenshotRequest.SCHEMA) .responseSchema(ScreenshotResponse.SCHEMA) - .register((server, client, request) -> service.screenshot(server, request)); + .register((server, client, params) -> { + try { + ScreenshotResponse response = service.screenshot(server, params); + RPCConnectionLogger.info(client.connectionId(), + String.format(Locale.ROOT, + "vision:screenshot - rendered %dx%d in %dms at (%f, %f, %f)", + response.width(), + response.height(), + response.renderTimeMs(), + params.position().get(0), + params.position().get(1), + params.position().get(2) + ) + ); + return response; + } catch (RuntimeException e) { + RPCConnectionLogger.warning(client.connectionId(), "vision:screenshot - " + e.getMessage()); + throw e; + } + }); } } diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java index e81d5ab..e242b32 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotRequest.java @@ -1,69 +1,89 @@ package dev.loat.msmp_vision.msmp.endpoints.screenshot; -import java.util.List; - import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; - import net.minecraft.server.jsonrpc.api.Schema; +import java.util.List; +import java.util.Optional; + + /** - * Parameters of the {@code msmp_vision:screenshot} method. + * Request payload for the {@code vision:screenshot} method. * - *

Optional numeric fields use a sentinel value meaning "use the configured default": - * {@code 0} for {@code width}, {@code height}, {@code fov}, {@code render_distance}, {@code ssaa} - * and {@code -1} for {@code biome_blend}.

+ *

Only {@code position} is required. Every omitted optional field falls back to the + * value from the {@code defaults} section of the config. Vectors are transferred as arrays, + * exactly like in the {@code entity} namespace.

* - * @param dimension Dimension id, e.g. {@code minecraft:overworld}. - * @param position The target position as {@code [x, y, z]} - * @param yaw Yaw in degrees (Minecraft convention, 0 = south, 90 = west). - * @param pitch Pitch in degrees (positive = looking down). - * @param width Output width in pixels, {@code 0} for default. - * @param height Output height in pixels, {@code 0} for default. - * @param fov Vertical field of view in degrees, {@code 0} for default. - * @param renderDistance Render distance in blocks, {@code 0} for default. - * @param ssaa Supersampling factor, {@code 0} for default. - * @param biomeBlend Biome tint blend radius, {@code -1} for default. + *

Example JSON representations:

+ *

+ * // Minimal
+ * { "position": [128.5, 80.0, -32.3] }
+ *
+ * // Everything set
+ * {
+ *   "dimension": "minecraft:overworld",
+ *   "position": [128.5, 80.0, -32.3],
+ *   "rotation": [90.0, 0.0],
+ *   "width": 512,
+ *   "height": 288,
+ *   "fov": 70.0,
+ *   "render_distance": 64,
+ *   "ssaa": 2,
+ *   "biome_blend": 1
+ * }
+ * 
+ * + * @param dimension The dimension resource key, if provided (default: {@code minecraft:overworld}) + * @param position The camera position as {@code [x, y, z]} + * @param rotation The camera rotation as {@code [yaw, pitch]}, if provided (Minecraft convention: yaw 0 = south, pitch positive = down; default: {@code [0, 0]}) + * @param width The output width in pixels, if provided + * @param height The output height in pixels, if provided + * @param fov The vertical field of view in degrees, if provided + * @param renderDistance The render distance in blocks, if provided + * @param ssaa The supersampling factor (1 = off), if provided + * @param biomeBlend The biome tint blend radius in blocks (0 = off), if provided */ public record ScreenshotRequest( - String dimension, + Optional dimension, List position, - float yaw, - float pitch, - int width, - int height, - double fov, - int renderDistance, - int ssaa, - int biomeBlend + Optional> rotation, + Optional width, + Optional height, + Optional fov, + Optional renderDistance, + Optional ssaa, + Optional biomeBlend ) { - - /** Default dimension used when the request omits one. */ - public static final String DEFAULT_DIMENSION = "minecraft:overworld"; private static final Schema> POSITION_SCHEMA = Schema.ofType("array", Codec.DOUBLE.listOf()); - - /** Codec used for (de)serialization. */ - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.STRING.optionalFieldOf("dimension", DEFAULT_DIMENSION).forGetter(ScreenshotRequest::dimension), + + private static final Schema> ROTATION_SCHEMA = + Schema.ofType("array", Codec.DOUBLE.listOf()); + + /** + * Codec for serializing and deserializing {@link ScreenshotRequest} instances. + */ + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( + Codec.STRING.optionalFieldOf("dimension").forGetter(ScreenshotRequest::dimension), Codec.DOUBLE.listOf().fieldOf("position").forGetter(ScreenshotRequest::position), - Codec.FLOAT.optionalFieldOf("yaw", 0f).forGetter(ScreenshotRequest::yaw), - Codec.FLOAT.optionalFieldOf("pitch", 0f).forGetter(ScreenshotRequest::pitch), - Codec.INT.optionalFieldOf("width", 0).forGetter(ScreenshotRequest::width), - Codec.INT.optionalFieldOf("height", 0).forGetter(ScreenshotRequest::height), - Codec.DOUBLE.optionalFieldOf("fov", 0.0).forGetter(ScreenshotRequest::fov), - Codec.INT.optionalFieldOf("render_distance", 0).forGetter(ScreenshotRequest::renderDistance), - Codec.INT.optionalFieldOf("ssaa", 0).forGetter(ScreenshotRequest::ssaa), - Codec.INT.optionalFieldOf("biome_blend", -1).forGetter(ScreenshotRequest::biomeBlend) - ).apply(instance, ScreenshotRequest::new)); - - /** MSMP discovery schema. */ + Codec.DOUBLE.listOf().optionalFieldOf("rotation").forGetter(ScreenshotRequest::rotation), + Codec.INT.optionalFieldOf("width").forGetter(ScreenshotRequest::width), + Codec.INT.optionalFieldOf("height").forGetter(ScreenshotRequest::height), + Codec.DOUBLE.optionalFieldOf("fov").forGetter(ScreenshotRequest::fov), + Codec.INT.optionalFieldOf("render_distance").forGetter(ScreenshotRequest::renderDistance), + Codec.INT.optionalFieldOf("ssaa").forGetter(ScreenshotRequest::ssaa), + Codec.INT.optionalFieldOf("biome_blend").forGetter(ScreenshotRequest::biomeBlend) + ).apply(i, ScreenshotRequest::new)); + + /** + * MSMP schema for {@link ScreenshotRequest}, used for protocol discovery. + */ public static final Schema SCHEMA = Schema.record(CODEC) .withField("dimension", Schema.STRING_SCHEMA) .withField("position", POSITION_SCHEMA) - .withField("yaw", Schema.NUMBER_SCHEMA) - .withField("pitch", Schema.NUMBER_SCHEMA) + .withField("rotation", ROTATION_SCHEMA) .withField("width", Schema.INT_SCHEMA) .withField("height", Schema.INT_SCHEMA) .withField("fov", Schema.NUMBER_SCHEMA) diff --git a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java index 6de4fb0..e07d835 100644 --- a/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java +++ b/src/main/java/dev/loat/msmp_vision/msmp/endpoints/screenshot/ScreenshotResponse.java @@ -6,27 +6,38 @@ /** - * Result of the {@code msmp_vision:screenshot} method. + * Response payload for the {@code vision:screenshot} method. * - * @param image Base64 encoded image data. - * @param format Image format, currently always {@code png}. - * @param width Image width in pixels. - * @param height Image height in pixels. - * @param renderTimeMs Total server-side time for the request in milliseconds. + *

Example JSON representation:

+ *

+ * {
+ *   "image": "iVBORw0KGgoAAAANSUhEUg...",
+ *   "format": "png",
+ *   "width": 512,
+ *   "height": 288,
+ *   "render_time_ms": 412
+ * }
+ * 
+ * + * @param image Base64 encoded image data (no data-URL prefix) + * @param format Image format, currently always {@code png} + * @param width Image width in pixels + * @param height Image height in pixels + * @param renderTimeMs Total server-side time for the request in milliseconds */ public record ScreenshotResponse(String image, String format, int width, int height, int renderTimeMs) { - + /** * Codec for serializing and deserializing {@link ScreenshotResponse} instances. */ - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group( Codec.STRING.fieldOf("image").forGetter(ScreenshotResponse::image), Codec.STRING.fieldOf("format").forGetter(ScreenshotResponse::format), Codec.INT.fieldOf("width").forGetter(ScreenshotResponse::width), Codec.INT.fieldOf("height").forGetter(ScreenshotResponse::height), Codec.INT.fieldOf("render_time_ms").forGetter(ScreenshotResponse::renderTimeMs) - ).apply(instance, ScreenshotResponse::new)); - + ).apply(i, ScreenshotResponse::new)); + /** * MSMP schema for {@link ScreenshotResponse}, used for protocol discovery. */ diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java new file mode 100644 index 0000000..dd71635 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/InvalidParamsException.java @@ -0,0 +1,17 @@ +package dev.loat.msmp_vision.msmp.exceptions; + +/** + * Thrown when a method receives invalid or incomplete parameters. + * + *

Used for method-specific validation errors, such as a wrongly sized + * {@code position} array or values exceeding the configured limits.

+ */ +public class InvalidParamsException extends MSMPException { + + /** + * @param message A description of which parameter is invalid and why + */ + public InvalidParamsException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java new file mode 100644 index 0000000..cc4b43e --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/MSMPException.java @@ -0,0 +1,22 @@ +package dev.loat.msmp_vision.msmp.exceptions; + +import net.minecraft.server.jsonrpc.methods.InvalidParameterJsonRpcException; + + +/** + * Base exception for all MSMP vision errors. + * + *

All method-specific exceptions extend this class, allowing handlers + * to catch all MSMP errors with a single {@code catch (MSMPException e)} block.

+ */ +public class MSMPException extends InvalidParameterJsonRpcException { + + /** + * Creates a new {@link MSMPException} with the given message. + * + * @param message A human-readable description of the error + */ + public MSMPException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java new file mode 100644 index 0000000..9d583a1 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/msmp/exceptions/UnknownDimensionException.java @@ -0,0 +1,14 @@ +package dev.loat.msmp_vision.msmp.exceptions; + +/** + * Thrown when a requested dimension identifier is not registered on the server. + */ +public class UnknownDimensionException extends MSMPException { + + /** + * @param dimension The dimension identifier that could not be resolved + */ + public UnknownDimensionException(String dimension) { + super("Unknown dimension: " + dimension); + } +} diff --git a/src/main/java/dev/loat/msmp_vision/raster/Fog.java b/src/main/java/dev/loat/msmp_vision/raster/Fog.java index fbfc5f5..fc049f6 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/Fog.java +++ b/src/main/java/dev/loat/msmp_vision/raster/Fog.java @@ -1,39 +1,78 @@ package dev.loat.msmp_vision.raster; /** - * Linear distance fog. + * Linear, spherical distance fog. * - * @param start Distance where fog begins. - * @param end Distance of full fog. - * @param r Fog red. - * @param g Fog green. - * @param b Fog blue. + *

The distance is the real distance from the camera to the fragment, not just the depth along the view axis, + * so the fog forms a sphere around the camera like in Minecraft instead of a flat wall in front of it.

*/ -public record Fog( - float start, - float end, - int r, - int g, - int b -) { +public final class Fog { + + private final float start; + private final float end; + private final int r; + private final int g; + private final int b; + private final float tanX; + private final float tanY; + private final float pixelToNdcX; + private final float pixelToNdcY; + private final float maxScale; + + private Fog(float start, float end, int r, int g, int b, float tanX, float tanY, int width, int height) { + this.start = start; + this.end = end; + this.r = r; + this.g = g; + this.b = b; + this.tanX = tanX; + this.tanY = tanY; + this.pixelToNdcX = 2f / width; + this.pixelToNdcY = 2f / height; + // Largest ratio of real distance to view depth, reached in the corners of the screen + this.maxScale = (float) Math.sqrt(1f + tanX * tanX + tanY * tanY); + } /** * Creates fog from a packed colour. * - * @param start Start distance. - * @param end End distance. + * @param start Distance in blocks where fog begins. + * @param end Distance in blocks of full fog. * @param rgb Colour as {@code 0xRRGGBB}. + * @param tanHalfFov {@code tan(fov / 2)} of the vertical field of view. + * @param aspect Width divided by height. + * @param width Framebuffer width. + * @param height Framebuffer height. * @return The fog. */ - public static Fog of(float start, float end, int rgb) { - return new Fog(start, end, (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF); + public static Fog of(float start, float end, int rgb, float tanHalfFov, float aspect, int width, int height) { + return new Fog(start, end, (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF, tanHalfFov * aspect, tanHalfFov, width, height); } + /** @return Fog red. */ + public int r() { return this.r; } + + /** @return Fog green. */ + public int g() { return this.g; } + + /** @return Fog blue. */ + public int b() { return this.b; } + /** - * @param distance View-space depth. + * Visibility of a fragment. + * + * @param x Pixel column. + * @param y Pixel row. + * @param w View-space depth of the fragment (distance along the view axis). * @return Visibility 1 (no fog) to 0 (full fog). */ - public float factor(float distance) { + public float factor(int x, int y, float w) { + if (w * this.maxScale <= this.start) { + return 1f; // even the farthest pixel of this depth is still before the fog + } + float nx = ((x + 0.5f) * this.pixelToNdcX - 1f) * this.tanX; + float ny = (1f - (y + 0.5f) * this.pixelToNdcY) * this.tanY; + float distance = w * (float) Math.sqrt(1f + nx * nx + ny * ny); float range = this.end - this.start; if (range <= 0) { return distance < this.end ? 1f : 0f; diff --git a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java index 105f1a7..5ec6021 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java +++ b/src/main/java/dev/loat/msmp_vision/raster/Framebuffer.java @@ -49,6 +49,10 @@ public BufferedImage toImage(int factor) { int outHeight = this.height / factor; BufferedImage image = new BufferedImage(outWidth, outHeight, BufferedImage.TYPE_INT_RGB); int[] target = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + if (factor == 1) { + System.arraycopy(this.color, 0, target, 0, target.length); + return image; + } int samples = factor * factor; for (int y = 0; y < outHeight; y++) { for (int x = 0; x < outWidth; x++) { diff --git a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java index c09c84a..4943169 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java +++ b/src/main/java/dev/loat/msmp_vision/raster/GeometryProjector.java @@ -59,10 +59,6 @@ public TriangleBuffer project(QuadBuffer quads) { private boolean load(QuadBuffer quads, int q) { float[] pos = quads.positions(); float[] uv = quads.uvs(); - int color = quads.color(q); - float r = ((color >> 16) & 0xFF) / 255f; - float g = ((color >> 8) & 0xFF) / 255f; - float b = (color & 0xFF) / 255f; boolean left = true, right = true, below = true, above = true, behind = true; for (int v = 0; v < 4; v++) { @@ -77,9 +73,10 @@ private boolean load(QuadBuffer quads, int q) { c[2] = w; c[3] = uv[q * 8 + v * 2]; c[4] = uv[q * 8 + v * 2 + 1]; - c[5] = r; - c[6] = g; - c[7] = b; + int color = quads.color(q, v); + c[5] = ((color >> 16) & 0xFF) / 255f; + c[6] = ((color >> 8) & 0xFF) / 255f; + c[7] = (color & 0xFF) / 255f; if (x >= -w) left = false; if (x <= w) right = false; if (y >= -w) below = false; @@ -106,9 +103,9 @@ private void emit(TriangleBuffer out, int texture, float[] a, float[] b, float[] d[2] = invW; d[3] = s[3] * invW; d[4] = s[4] * invW; - d[5] = s[5]; - d[6] = s[6]; - d[7] = s[7]; + d[5] = s[5] * invW; + d[6] = s[6] * invW; + d[7] = s[7] * invW; } for (int i = 1; i < n - 1; i++) { float[] p = this.screen[0]; diff --git a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java index 6a20ae8..fd6b875 100644 --- a/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java +++ b/src/main/java/dev/loat/msmp_vision/raster/TileRasterizer.java @@ -183,10 +183,10 @@ private void drawTriangle(float[] d, int o, TextureData texture, boolean translu if (alpha == 0) { continue; } - float lr = (float) (w0 * ar + w1 * br + w2 * cr); - float lg = (float) (w0 * ag + w1 * bg + w2 * cg); - float lb = (float) (w0 * ab + w1 * bb + w2 * cb); - float f = fog.factor(w); + float lr = (float) (w0 * ar + w1 * br + w2 * cr) * w; + float lg = (float) (w0 * ag + w1 * bg + w2 * cg) * w; + float lb = (float) (w0 * ab + w1 * bb + w2 * cb) * w; + float f = fog.factor(x, y, w); int r = Math.min(255, (int) (((texel >> 16) & 0xFF) * lr * f + fog.r() * (1f - f))); int g = Math.min(255, (int) (((texel >> 8) & 0xFF) * lg * f + fog.g() * (1f - f))); int b = Math.min(255, (int) ((texel & 0xFF) * lb * f + fog.b() * (1f - f))); diff --git a/src/main/java/dev/loat/msmp_vision/render/Camera.java b/src/main/java/dev/loat/msmp_vision/render/Camera.java index f5c6518..311346f 100644 --- a/src/main/java/dev/loat/msmp_vision/render/Camera.java +++ b/src/main/java/dev/loat/msmp_vision/render/Camera.java @@ -50,7 +50,7 @@ public Camera(RenderSettings settings) { (float) (settings.position().get(2) - this.originZ) ); Matrix4f matrix = new Matrix4f() - .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistance() + 64f) + .perspective((float) Math.toRadians(settings.fov()), this.aspect, 0.05f, settings.renderDistanceBlocks() + 64f) .lookAt(eye, new Vector3f(eye).add(this.forward), new Vector3f(0, 1, 0)); matrix.get(this.viewProjection); this.frustum = new FrustumIntersection(matrix); @@ -97,4 +97,19 @@ public boolean intersectsSection(int sectionX, int sectionY, int sectionZ) { float minZ = (sectionZ << 4) - this.originZ; return this.frustum.testAab(minX, minY, minZ, minX + 16, minY + 16, minZ + 16); } + + /** + * Tests whether a whole chunk column may be visible. Used to decide which chunks are worth loading. + * + * @param chunkX Chunk X. + * @param chunkZ Chunk Z. + * @param minY Lowest block Y of the world. + * @param maxY Highest block Y of the world, exclusive. + * @return {@code false} if the column is certainly outside the view frustum. + */ + public boolean intersectsColumn(int chunkX, int chunkZ, int minY, int maxY) { + float minX = (chunkX << 4) - this.originX; + float minZ = (chunkZ << 4) - this.originZ; + return this.frustum.testAab(minX, minY - this.originY, minZ, minX + 16, maxY - this.originY, minZ + 16); + } } diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java index ade7cda..6440803 100644 --- a/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java +++ b/src/main/java/dev/loat/msmp_vision/render/RenderExecutor.java @@ -20,10 +20,11 @@ public final class RenderExecutor { /** * Creates the pool. * - * @param configuredThreads Thread count, {@code 0} selects the number of available processors. + * @param configuredThreads Thread count, {@code 0} selects the number of available processors minus one (at least 1), + * so the server thread keeps a core. */ public RenderExecutor(int configuredThreads) { - this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors()); + this.threads = configuredThreads > 0 ? configuredThreads : Math.max(1, Runtime.getRuntime().availableProcessors() - 1); AtomicInteger counter = new AtomicInteger(); this.pool = Executors.newFixedThreadPool(this.threads, runnable -> { Thread thread = new Thread(runnable, "msmp-vision-render-" + counter.incrementAndGet()); diff --git a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java index 99ce9d5..4943a47 100644 --- a/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java +++ b/src/main/java/dev/loat/msmp_vision/render/RenderSettings.java @@ -6,21 +6,20 @@ import dev.loat.msmp_vision.config.files.defaults.DefaultsConfig; import dev.loat.msmp_vision.config.files.limits.LimitsConfig; import dev.loat.msmp_vision.msmp.endpoints.screenshot.ScreenshotRequest; +import dev.loat.msmp_vision.msmp.exceptions.InvalidParamsException; /** * Immutable, validated parameters of a single render. The only place where request values, config defaults * and config limits are combined; every renderer stage reads from here. * - * @param x Camera X. - * @param y Camera Y. - * @param z Camera Z. + * @param position Camera position as {@code [x, y, z]}. * @param yaw Yaw in degrees (Minecraft convention, 0 = south). * @param pitch Pitch in degrees (positive = down). * @param width Output width in pixels. * @param height Output height in pixels. * @param fov Vertical field of view in degrees. - * @param renderDistance Render distance in blocks. + * @param renderDistance Render distance in chunks (1 chunk = 16 blocks), like in Minecraft's video settings. * @param ssaa Supersampling factor (1 = off). * @param biomeBlend Biome tint blend radius in blocks. */ @@ -36,36 +35,54 @@ public record RenderSettings( int biomeBlend ) { + /** Pitch is clamped slightly below +-90 degrees so the camera's up vector never degenerates. */ + private static final double MAX_PITCH = 89.9; + /** * Builds validated settings. * - * @param request The request; zero values select the config default. + * @param request The request; omitted optional values select the config default. * @param config The configuration providing defaults and limits. * @return The settings. - * @throws IllegalArgumentException if a value exceeds the configured limits. + * @throws InvalidParamsException if a value is malformed or exceeds the configured limits. */ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFile config) { DefaultsConfig defaultsConfig = config.defaults; LimitsConfig limitsConfig = config.limits; - int width = request.width() > 0 ? request.width() : defaultsConfig.width; - int height = request.height() > 0 ? request.height() : defaultsConfig.height; - double fov = request.fov() > 0 ? request.fov() : defaultsConfig.fov; - int distance = request.renderDistance() > 0 ? request.renderDistance() : defaultsConfig.renderDistance; - int ssaa = request.ssaa() > 0 ? request.ssaa() : defaultsConfig.ssaa; - int blend = request.biomeBlend() >= 0 ? request.biomeBlend() : defaultsConfig.biomeBlend; - - require(width <= limitsConfig.maxWidth, "width exceeds the limit of " + limitsConfig.maxWidth); - require(height <= limitsConfig.maxHeight, "height exceeds the limit of " + limitsConfig.maxHeight); - require(distance <= limitsConfig.maxRenderDistance, "renderDistance exceeds the limit of " + limitsConfig.maxRenderDistance); - require(ssaa <= limitsConfig.maxSsaa, "ssaa exceeds the limit of " + limitsConfig.maxSsaa); - require(fov >= limitsConfig.minFov && fov <= limitsConfig.maxFov, "fov must be between " + limitsConfig.minFov + " and " + limitsConfig.maxFov); + List position = request.position(); + require(position.size() == 3, "'position' must contain exactly 3 elements [x, y, z], got " + position.size()); + + List rotation = request.rotation().orElse(List.of(0.0, 0.0)); + require(rotation.size() == 2, "'rotation' must contain exactly 2 elements [yaw, pitch], got " + rotation.size()); + + require(position.stream().allMatch(Double::isFinite), "'position' must only contain finite numbers"); + require(rotation.stream().allMatch(Double::isFinite), "'rotation' must only contain finite numbers"); + + int width = request.width().orElse(defaultsConfig.width); + int height = request.height().orElse(defaultsConfig.height); + double fov = request.fov().orElse(defaultsConfig.fov); + int distance = request.renderDistance().orElse(defaultsConfig.renderDistanceChunks); + int ssaa = request.ssaa().orElse(defaultsConfig.ssaa); + int blend = request.biomeBlend().orElse(defaultsConfig.biomeBlend); + + require(width > 0, "'width' must be positive"); + require(height > 0, "'height' must be positive"); + require(distance > 0, "'render_distance' must be positive"); + require(ssaa > 0, "'ssaa' must be positive"); + require(blend >= 0, "'biome_blend' must not be negative"); + + require(width <= limitsConfig.maxWidth, "'width' exceeds the limit of " + limitsConfig.maxWidth); + require(height <= limitsConfig.maxHeight, "'height' exceeds the limit of " + limitsConfig.maxHeight); + require(distance <= limitsConfig.maxRenderDistanceChunks, "'render_distance' exceeds the limit of " + limitsConfig.maxRenderDistanceChunks + " chunks"); + require(ssaa <= limitsConfig.maxSsaa, "'ssaa' exceeds the limit of " + limitsConfig.maxSsaa); + require(fov >= limitsConfig.minFov && fov <= limitsConfig.maxFov, "'fov' must be between " + limitsConfig.minFov + " and " + limitsConfig.maxFov); require((long) width * ssaa * height * ssaa <= limitsConfig.maxRenderedPixels, "width*height*ssaa^2 exceeds " + limitsConfig.maxRenderedPixels + " pixels"); return new RenderSettings( - request.position(), - request.yaw(), - request.pitch(), + List.copyOf(position), + rotation.get(0), + Math.max(-MAX_PITCH, Math.min(MAX_PITCH, rotation.get(1))), width, height, fov, @@ -75,6 +92,11 @@ public static RenderSettings from(ScreenshotRequest request, MSMPVisionConfigFil ); } + /** @return The render distance in blocks. */ + public int renderDistanceBlocks() { + return this.renderDistance * 16; + } + /** @return Framebuffer width including supersampling. */ public int renderWidth() { return this.width * this.ssaa; @@ -87,7 +109,7 @@ public int renderHeight() { private static void require(boolean condition, String message) { if (!condition) { - throw new IllegalArgumentException(message); + throw new InvalidParamsException(message); } } } diff --git a/src/main/java/dev/loat/msmp_vision/render/Rendered.java b/src/main/java/dev/loat/msmp_vision/render/Rendered.java new file mode 100644 index 0000000..adce70e --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/render/Rendered.java @@ -0,0 +1,14 @@ +package dev.loat.msmp_vision.render; + +import java.awt.image.BufferedImage; + +/** + * Result of a render together with the time each phase took. + * + * @param image The final image. + * @param meshNanos Time spent meshing and projecting all sections. + * @param skyNanos Time spent painting the sky. + * @param tileNanos Time spent rasterizing the tiles. + * @param downsampleNanos Time spent downsampling the supersampled framebuffer. + */ +public record Rendered(BufferedImage image, long meshNanos, long skyNanos, long tileNanos, long downsampleNanos) {} diff --git a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java index aabe3e7..255f0d2 100644 --- a/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java +++ b/src/main/java/dev/loat/msmp_vision/render/SceneRenderer.java @@ -56,13 +56,14 @@ public SceneRenderer(AssetManager assets, TextureStore textures, RenderExecutor * @param camera The camera. * @param settings The settings. * @param config The current configuration. - * @return The final image. + * @return The final image together with phase timings. */ - public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { + public Rendered render(WorldSnapshot world, Camera camera, RenderSettings settings, MSMPVisionConfigFile config) { + long startNanos = System.nanoTime(); long timeout = config.requestTimeoutSeconds; int width = settings.renderWidth(); int height = settings.renderHeight(); - boolean cull = config.rendering.backfaceCulling; + boolean cull = config.render.backfaceCulling; List> jobs = new ArrayList<>(); for (SectionSnapshot section : world.sections()) { @@ -70,12 +71,13 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s continue; } jobs.add(CompletableFuture.supplyAsync(() -> { - SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend()).mesh(section); + SectionGeometry geometry = new SectionMesher(world, this.baker, this.textures, this.tint, camera, settings.biomeBlend(), config.render.smoothLighting).mesh(section); GeometryProjector projector = new GeometryProjector(camera.viewProjection(), width, height, cull); return new Projected(projector.project(geometry.opaque()), projector.project(geometry.translucent())); }, this.executor.executor())); } this.executor.awaitAll(jobs, timeout); + long meshedNanos = System.nanoTime(); List opaque = new ArrayList<>(jobs.size()); List translucent = new ArrayList<>(jobs.size()); @@ -86,13 +88,19 @@ public BufferedImage render(WorldSnapshot world, Camera camera, RenderSettings s } Framebuffer framebuffer = new Framebuffer(width, height); - SkyPainter.paint(framebuffer, camera, world.environment()); + long skyStart = System.nanoTime(); + SkyPainter.paint(framebuffer, camera, world.environment(), this.executor, timeout); + long skyDone = System.nanoTime(); - float fogEnd = settings.renderDistance(); - Fog fog = Fog.of((float) (fogEnd * config.rendering.fogStartFactor), fogEnd, world.environment().fogColor()); - new TileRasterizer(this.executor, config.rendering.tileSize, timeout) + float fogEnd = settings.renderDistanceBlocks(); + Fog fog = Fog.of((float) (fogEnd * config.render.fogStartFactor), fogEnd, world.environment().fogColor(), + camera.tanHalfFov(), camera.aspect(), width, height); + new TileRasterizer(this.executor, config.render.tileSize, timeout) .rasterize(TriangleBuffer.merge(opaque), TriangleBuffer.merge(translucent), framebuffer, this.texturesById(), fog); - return framebuffer.toImage(settings.ssaa()); + long tilesDone = System.nanoTime(); + BufferedImage image = framebuffer.toImage(settings.ssaa()); + long doneNanos = System.nanoTime(); + return new Rendered(image, meshedNanos - startNanos, skyDone - skyStart, tilesDone - skyDone, doneNanos - tilesDone); } private TextureData[] texturesById() { diff --git a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java index 5db0284..9c8018c 100644 --- a/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java +++ b/src/main/java/dev/loat/msmp_vision/render/SkyPainter.java @@ -4,40 +4,66 @@ import dev.loat.msmp_vision.world.EnvironmentSnapshot; import org.joml.Vector3f; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; /** * Fills the framebuffer with a sky gradient, sun and moon. The horizon colour equals the fog colour so that - * distant terrain blends seamlessly into the sky. + * distant terrain blends seamlessly into the sky; the colour overhead is the sky colour of the environment. + * Both already contain the time of day, so they are used as they are. */ public final class SkyPainter { - private static final int ZENITH_DAY = 0x78A7FF; - private static final float SUN_COS = 0.997f; - private static final float MOON_COS = 0.9985f; + /** Distance of the horizon haze: sky blends from fog colour to sky colour over this much of {@code dir.y}. */ + private static final float HAZE_HEIGHT = 0.45f; + /** Half edge length of the sun / moon quad, as {@code tan(angle)}; vanilla uses a 30 unit quad at 100 units. */ + private static final float SUN_HALF = 0.15f; + private static final float MOON_HALF = 0.10f; + + /** Rows per parallel job. */ + private static final int BAND_ROWS = 32; private SkyPainter() {} /** - * Paints the sky and clears depth. + * Paints the sky and clears depth. The image is split into bands of rows that are painted in parallel. * * @param target The framebuffer. * @param camera The camera. * @param environment Environment values. + * @param executor Worker pool. + * @param timeoutSeconds Maximum time for the whole sky. */ - public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment) { - int width = target.width(); - int height = target.height(); - int[] color = target.color(); + public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot environment, RenderExecutor executor, long timeoutSeconds) { Arrays.fill(target.depth(), 0f); - float day = Math.max(environment.skyLightFactor(), 0.03f); float[] horizon = rgb(environment.fogColor()); - float[] zenith = {((ZENITH_DAY >> 16) & 0xFF) * day, ((ZENITH_DAY >> 8) & 0xFF) * day, (ZENITH_DAY & 0xFF) * day}; - Vector3f sun = new Vector3f((float) -Math.sin(environment.sunAngleRadians()), (float) Math.cos(environment.sunAngleRadians()), 0).normalize(); + float[] zenith = rgb(environment.skyColor()); + + double angle = environment.sunAngleRadians(); + Vector3f sun = new Vector3f((float) -Math.sin(angle), (float) Math.cos(angle), 0).normalize(); + Vector3f tangent = new Vector3f((float) Math.cos(angle), (float) Math.sin(angle), 0); + + int height = target.height(); + List> jobs = new ArrayList<>(); + for (int from = 0; from < height; from += BAND_ROWS) { + int start = from; + int end = Math.min(height, from + BAND_ROWS); + jobs.add(CompletableFuture.runAsync( + () -> paintRows(target, camera, horizon, zenith, sun, tangent, start, end), executor.executor())); + } + executor.awaitAll(jobs, timeoutSeconds); + } + private static void paintRows(Framebuffer target, Camera camera, float[] horizon, float[] zenith, + Vector3f sun, Vector3f tangent, int fromRow, int toRow) { + int width = target.width(); + int height = target.height(); + int[] color = target.color(); Vector3f dir = new Vector3f(); - for (int y = 0; y < height; y++) { + for (int y = fromRow; y < toRow; y++) { float ndcY = 1f - 2f * (y + 0.5f) / height; for (int x = 0; x < width; x++) { float ndcX = 2f * (x + 0.5f) / width - 1f; @@ -45,22 +71,37 @@ public static void paint(Framebuffer target, Camera camera, EnvironmentSnapshot .fma(ndcX * camera.tanHalfFov() * camera.aspect(), camera.right()) .fma(ndcY * camera.tanHalfFov(), camera.up()) .normalize(); - float t = (float) Math.sqrt(Math.max(dir.y, 0f)); + + float t = smoothstep(Math.max(dir.y, 0f) / HAZE_HEIGHT); float r = horizon[0] + (zenith[0] - horizon[0]) * t; float g = horizon[1] + (zenith[1] - horizon[1]) * t; float b = horizon[2] + (zenith[2] - horizon[2]) * t; - float sunDot = dir.dot(sun); - if (sunDot > SUN_COS) { - r = 255; g = 235; b = 150; - } else if (-sunDot > MOON_COS) { - r = 225; g = 225; b = 232; + if (dir.y > -0.02f) { + float d = dir.dot(sun); + if (d > 0.01f && inQuad(dir, tangent, d, SUN_HALF)) { + r = 255; g = 255; b = 255; + } else if (d < -0.01f && inQuad(dir, tangent, -d, MOON_HALF)) { + r = 225; g = 228; b = 238; + } } color[y * width + x] = 0xFF000000 | (clamp(r) << 16) | (clamp(g) << 8) | clamp(b); } } } + /** Tests whether a view direction hits a square billboard facing the sun axis, projected onto the plane at distance 1. */ + private static boolean inQuad(Vector3f dir, Vector3f tangent, float axisDot, float half) { + float u = dir.dot(tangent) / axisDot; + float v = dir.z / axisDot; + return Math.abs(u) < half && Math.abs(v) < half; + } + + private static float smoothstep(float x) { + float t = Math.max(0f, Math.min(1f, x)); + return t * t * (3f - 2f * t); + } + private static float[] rgb(int packed) { return new float[]{(packed >> 16) & 0xFF, (packed >> 8) & 0xFF, packed & 0xFF}; } diff --git a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java index 1f11b27..9f389b6 100644 --- a/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java +++ b/src/main/java/dev/loat/msmp_vision/world/EnvironmentSnapshot.java @@ -5,12 +5,14 @@ * * @param sunAngleRadians Sun angle in radians (0 = zenith). * @param skyLightFactor Sky light multiplier, 0 (night) to 1 (day). - * @param fogColor Fog colour as {@code 0xRRGGBB}. + * @param fogColor Fog colour as {@code 0xRRGGBB}; also the colour of the sky at the horizon. + * @param skyColor Sky colour as {@code 0xRRGGBB}; the colour of the sky overhead. * @param hasSkyLight Whether the dimension has sky light. */ public record EnvironmentSnapshot( double sunAngleRadians, float skyLightFactor, int fogColor, + int skyColor, boolean hasSkyLight ) {} diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java index a108624..ebea476 100644 --- a/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java +++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotCapture.java @@ -1,80 +1,176 @@ package dev.loat.msmp_vision.world; +import dev.loat.msmp_vision.config.files.chunks.ChunksConfig; +import dev.loat.msmp_vision.logging.Logger; import dev.loat.msmp_vision.render.Camera; import dev.loat.msmp_vision.render.RenderSettings; import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.util.Mth; import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.DataLayer; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.lighting.LevelLightEngine; import net.minecraft.world.phys.Vec3; -import org.joml.Vector3f; +import org.joml.Vector3fc; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; /** * Copies the chunk sections around the camera into a {@link WorldSnapshot}. Must run on the server thread. + * + *

Chunks that are not loaded are loaded on demand (see {@link ChunksConfig}). Each chunk is copied into + * the snapshot right after it became available, so it does not have to stay loaded until the render starts. + * Chunks are processed nearest first, so a limit or timeout only costs the far end of the picture.

*/ public final class SnapshotCapture { + private static final int FALLBACK_FOG = 0xC0D8FF; + private static final int FALLBACK_SKY = 0x78A7FF; + + /** A chunk column that may be visible, with its squared horizontal distance to the camera. */ + private record Column(int x, int z, double distanceSquared) {} + private SnapshotCapture() {} /** - * Captures all loaded, potentially visible sections. + * Captures all potentially visible sections, loading missing chunks if configured. * * @param level The level. * @param camera The camera used for frustum culling. * @param settings The render settings. + * @param chunks The chunk loading configuration. * @return The snapshot. */ - public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings) { + public static WorldSnapshot capture(ServerLevel level, Camera camera, RenderSettings settings, ChunksConfig chunks) { Vec3 position = new Vec3(settings.position().get(0), settings.position().get(1), settings.position().get(2)); - Vector3f fog = new Vector3f(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position)).mul(255.0f); EnvironmentSnapshot environment = new EnvironmentSnapshot( level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD, level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position), - (int) fog.x << 16 | (int) fog.y << 8 | (int) fog.z, + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position), FALLBACK_FOG), + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position), FALLBACK_SKY), level.dimensionType().hasSkyLight() ); WorldSnapshot snapshot = new WorldSnapshot(environment); - double radius = settings.renderDistance() + 16; - int minChunkX = Mth.floor(settings.position().get(0) - radius) >> 4; - int maxChunkX = Mth.floor(settings.position().get(0) + radius) >> 4; - int minChunkZ = Mth.floor(settings.position().get(2) - radius) >> 4; - int maxChunkZ = Mth.floor(settings.position().get(2) + radius) >> 4; - int minSectionY = level.getMinSectionY(); + double radius = settings.renderDistanceBlocks() + 16; + ServerChunkCache cache = level.getChunkSource(); LevelLightEngine light = level.getLightEngine(); + int minSectionY = level.getMinSectionY(); - for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { - for (int cx = minChunkX; cx <= maxChunkX; cx++) { - LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); - if (chunk == null) { + long start = System.nanoTime(); + long deadline = start + TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds)); + List columns = visibleColumns(level, camera, settings, radius); + int resident = 0; + int loaded = 0; + int skipped = 0; + + for (Column column : columns) { + LevelChunk chunk = cache.getChunkNow(column.x(), column.z()); + if (chunk != null) { + resident++; + } else if (chunks.mode == ChunksConfig.Mode.LOAD) { + if (loaded >= chunks.maxChunkLoads || System.nanoTime() >= deadline) { + skipped++; continue; } - LevelChunkSection[] sections = chunk.getSections(); - for (int i = 0; i < sections.length; i++) { - int sy = minSectionY + i; - if (!isRelevant(camera, settings, radius, cx, sy, cz)) { - continue; - } - LevelChunkSection section = sections[i]; - SectionPos pos = SectionPos.of(cx, sy, cz); - snapshot.put(new SectionSnapshot( - cx, sy, cz, - section.hasOnlyAir() ? null : section.getStates().copy(), - section.getBiomes().copy(), - environment.hasSkyLight() ? copyLayer(light, LightLayer.SKY, pos) : null, - copyLayer(light, LightLayer.BLOCK, pos) - )); + ChunkAccess access = cache.getChunk(column.x(), column.z(), ChunkStatus.FULL, true); + loaded++; + if (access instanceof LevelChunk levelChunk) { + chunk = levelChunk; } } + if (chunk != null) { + copyColumn(snapshot, chunk, column, camera, settings, radius, light, minSectionY, environment.hasSkyLight()); + } + } + + Logger.info("Captured %d section(s) from %d chunk column(s) in view at render distance %d chunks (%d already loaded, %d newly loaded, %d skipped) in %d ms." + .formatted(snapshot.sections().size(), columns.size(), settings.renderDistance(), resident, loaded, skipped, (System.nanoTime() - start) / 1_000_000L)); + if (skipped > 0) { + Logger.warning("Chunk loading limit reached: %d chunk(s) were not loaded and are missing in the screenshot. Raise 'chunks.max-chunk-loads' or 'chunks.load-timeout-seconds', or lower the render distance." + .formatted(skipped)); } return snapshot; } + /** All chunk columns inside the render radius and the view frustum, nearest first. */ + private static List visibleColumns(ServerLevel level, Camera camera, RenderSettings settings, double radius) { + double px = settings.position().get(0); + double pz = settings.position().get(2); + int minChunkX = Mth.floor(px - radius) >> 4; + int maxChunkX = Mth.floor(px + radius) >> 4; + int minChunkZ = Mth.floor(pz - radius) >> 4; + int maxChunkZ = Mth.floor(pz + radius) >> 4; + // Only sections within the render radius are ever used, so the column test only needs that height range. + // Testing the full world height would let the tilted frustum planes pick up many columns that can never be seen. + double py = settings.position().get(1); + int minY = Math.max(level.getMinY(), Mth.floor(py - radius)); + int maxY = Math.min(level.getMinY() + level.getHeight(), Mth.ceil(py + radius)); + double reach = radius + 12; // half diagonal of a chunk + + List columns = new ArrayList<>(); + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + double dx = (cx << 4) + 8 - px; + double dz = (cz << 4) + 8 - pz; + double distanceSquared = dx * dx + dz * dz; + if (distanceSquared > reach * reach || !camera.intersectsColumn(cx, cz, minY, maxY)) { + continue; + } + columns.add(new Column(cx, cz, distanceSquared)); + } + } + columns.sort(Comparator.comparingDouble(Column::distanceSquared)); + return columns; + } + + private static void copyColumn(WorldSnapshot snapshot, LevelChunk chunk, Column column, Camera camera, RenderSettings settings, + double radius, LevelLightEngine light, int minSectionY, boolean hasSkyLight) { + LevelChunkSection[] sections = chunk.getSections(); + for (int i = 0; i < sections.length; i++) { + int sy = minSectionY + i; + if (!isRelevant(camera, settings, radius, column.x(), sy, column.z())) { + continue; + } + LevelChunkSection section = sections[i]; + SectionPos pos = SectionPos.of(column.x(), sy, column.z()); + snapshot.put(new SectionSnapshot( + column.x(), sy, column.z(), + section.hasOnlyAir() ? null : section.getStates().copy(), + section.getBiomes().copy(), + hasSkyLight ? copyLayer(light, LightLayer.SKY, pos) : null, + copyLayer(light, LightLayer.BLOCK, pos) + )); + } + } + + /** + * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts packed integer colours (alpha is ignored) + * as well as RGB vectors (components 0..1) and clamps everything so nothing can overflow into a neighbouring channel. + */ + private static int toRgb(Object raw, int fallback) { + if (raw instanceof Integer packed) { + return packed & 0xFFFFFF; + } + if (raw instanceof Vector3fc vector) { + return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z()); + } + return fallback; + } + + private static int channel(float unit) { + return Mth.clamp((int) (unit * 255.0f + 0.5f), 0, 255); + } + private static 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); diff --git a/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java b/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java new file mode 100644 index 0000000..9c28541 --- /dev/null +++ b/src/main/java/dev/loat/msmp_vision/world/SnapshotSession.java @@ -0,0 +1,234 @@ +package dev.loat.msmp_vision.world; + +import dev.loat.msmp_vision.config.files.chunks.ChunksConfig; +import dev.loat.msmp_vision.logging.Logger; +import dev.loat.msmp_vision.render.Camera; +import dev.loat.msmp_vision.render.RenderSettings; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.world.attribute.EnvironmentAttributes; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.phys.Vec3; +import org.joml.Vector3fc; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Copies the chunk sections around the camera into a {@link WorldSnapshot}, in slices of limited duration. + * Every method must be called on the server thread. + * + *

Chunks that are not loaded are loaded on demand (see {@link ChunksConfig}). Each chunk is copied into + * the snapshot right after it became available, so it does not have to stay loaded until the render starts. + * Chunks are processed nearest first, so a limit or timeout only costs the far end of the picture.

+ * + *

{@link #step(long)} does a bounded amount of work and can be called once per server tick, which keeps the + * server responsive while a large area is loaded. Calling it with {@link Long#MAX_VALUE} does everything at once.

+ */ +public final class SnapshotSession { + + private static final int FALLBACK_FOG = 0xC0D8FF; + private static final int FALLBACK_SKY = 0x78A7FF; + + /** A chunk column that may be visible, with its squared horizontal distance to the camera. */ + private record Column(int x, int z, double distanceSquared) {} + + private final ServerLevel level; + private final Camera camera; + private final RenderSettings settings; + private final ChunksConfig chunks; + private final WorldSnapshot snapshot; + private final EnvironmentSnapshot environment; + private final List columns; + private final double radius; + private final int minSectionY; + private final long loadLimitNanos; + + private int next; + private int resident; + private int loaded; + private int skipped; + private long workNanos; + private boolean finished; + + /** + * Prepares a capture: reads the environment and determines which chunk columns are in view. + * + * @param level The level. + * @param camera The camera used for frustum culling. + * @param settings The render settings. + * @param chunks The chunk loading configuration. + */ + public SnapshotSession(ServerLevel level, Camera camera, RenderSettings settings, ChunksConfig chunks) { + this.level = level; + this.camera = camera; + this.settings = settings; + this.chunks = chunks; + + Vec3 position = new Vec3(settings.position().get(0), settings.position().get(1), settings.position().get(2)); + this.environment = new EnvironmentSnapshot( + level.environmentAttributes().getValue(EnvironmentAttributes.SUN_ANGLE, position) * Mth.DEG_TO_RAD, + level.environmentAttributes().getValue(EnvironmentAttributes.SKY_LIGHT_FACTOR, position), + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.FOG_COLOR, position), FALLBACK_FOG), + toRgb(level.environmentAttributes().getValue(EnvironmentAttributes.SKY_COLOR, position), FALLBACK_SKY), + level.dimensionType().hasSkyLight() + ); + this.snapshot = new WorldSnapshot(this.environment); + this.radius = settings.renderDistanceBlocks() + 16; + this.minSectionY = level.getMinSectionY(); + this.loadLimitNanos = TimeUnit.SECONDS.toNanos(Math.max(0, chunks.loadTimeoutSeconds)); + this.columns = visibleColumns(level, camera, settings, this.radius); + } + + /** + * Processes chunk columns until the budget is used up (at least one column per call). + * + * @param budgetNanos Maximum time for this call, {@link Long#MAX_VALUE} for no limit. + * @return {@code true} once the capture is complete. + */ + public boolean step(long budgetNanos) { + if (this.finished) { + return true; + } + ServerChunkCache cache = this.level.getChunkSource(); + LevelLightEngine light = this.level.getLightEngine(); + long stepStart = System.nanoTime(); + int processed = 0; + + while (this.next < this.columns.size()) { + if (processed > 0 && budgetNanos != Long.MAX_VALUE && System.nanoTime() - stepStart >= budgetNanos) { + break; + } + Column column = this.columns.get(this.next++); + processed++; + + LevelChunk chunk = cache.getChunkNow(column.x(), column.z()); + if (chunk != null) { + this.resident++; + } else if (this.chunks.mode == ChunksConfig.Mode.LOAD) { + long spent = this.workNanos + (System.nanoTime() - stepStart); + if (this.loaded >= this.chunks.maxChunkLoads || spent >= this.loadLimitNanos) { + this.skipped++; + continue; + } + ChunkAccess access = cache.getChunk(column.x(), column.z(), ChunkStatus.FULL, true); + this.loaded++; + if (access instanceof LevelChunk levelChunk) { + chunk = levelChunk; + } + } + if (chunk != null) { + this.copyColumn(chunk, column, light); + } + } + this.workNanos += System.nanoTime() - stepStart; + + if (this.next < this.columns.size()) { + return false; + } + this.finished = true; + Logger.info("Captured %d section(s) from %d chunk column(s) in view at render distance %d chunks (%d already loaded, %d newly loaded, %d skipped) in %d ms." + .formatted(this.snapshot.sections().size(), this.columns.size(), this.settings.renderDistance(), this.resident, this.loaded, this.skipped, this.workNanos / 1_000_000L)); + if (this.skipped > 0) { + Logger.warning("Chunk loading limit reached: %d chunk(s) were not loaded and are missing in the screenshot. Raise 'chunks.max-chunk-loads' or 'chunks.load-timeout-seconds', or lower the render distance." + .formatted(this.skipped)); + } + return true; + } + + /** @return The snapshot; only complete once {@link #step(long)} returned {@code true}. */ + public WorldSnapshot snapshot() { return this.snapshot; } + + /** @return Total time in nanoseconds spent inside {@link #step(long)}, i.e. blocked on the server thread. */ + public long workNanos() { return this.workNanos; } + + /** All chunk columns inside the render radius and the view frustum, nearest first. */ + private static List visibleColumns(ServerLevel level, Camera camera, RenderSettings settings, double radius) { + double px = settings.position().get(0); + double pz = settings.position().get(2); + int minChunkX = Mth.floor(px - radius) >> 4; + int maxChunkX = Mth.floor(px + radius) >> 4; + int minChunkZ = Mth.floor(pz - radius) >> 4; + int maxChunkZ = Mth.floor(pz + radius) >> 4; + // Only sections within the render radius are ever used, so the column test only needs that height range. + // Testing the full world height would let the tilted frustum planes pick up many columns that can never be seen. + double py = settings.position().get(1); + int minY = Math.max(level.getMinY(), Mth.floor(py - radius)); + int maxY = Math.min(level.getMinY() + level.getHeight(), Mth.ceil(py + radius)); + double reach = radius + 12; // half diagonal of a chunk + + List columns = new ArrayList<>(); + for (int cz = minChunkZ; cz <= maxChunkZ; cz++) { + for (int cx = minChunkX; cx <= maxChunkX; cx++) { + double dx = (cx << 4) + 8 - px; + double dz = (cz << 4) + 8 - pz; + double distanceSquared = dx * dx + dz * dz; + if (distanceSquared > reach * reach || !camera.intersectsColumn(cx, cz, minY, maxY)) { + continue; + } + columns.add(new Column(cx, cz, distanceSquared)); + } + } + columns.sort(Comparator.comparingDouble(Column::distanceSquared)); + return columns; + } + + private void copyColumn(LevelChunk chunk, Column column, LevelLightEngine light) { + LevelChunkSection[] sections = chunk.getSections(); + for (int i = 0; i < sections.length; i++) { + int sy = this.minSectionY + i; + if (!this.isRelevant(column.x(), sy, column.z())) { + continue; + } + LevelChunkSection section = sections[i]; + SectionPos pos = SectionPos.of(column.x(), sy, column.z()); + this.snapshot.put(new SectionSnapshot( + column.x(), sy, column.z(), + section.hasOnlyAir() ? null : section.getStates().copy(), + section.getBiomes().copy(), + this.environment.hasSkyLight() ? copyLayer(light, LightLayer.SKY, pos) : null, + copyLayer(light, LightLayer.BLOCK, pos) + )); + } + } + + private boolean isRelevant(int cx, int sy, int cz) { + double dx = (cx << 4) + 8 - this.settings.position().get(0); + double dy = (sy << 4) + 8 - this.settings.position().get(1); + double dz = (cz << 4) + 8 - this.settings.position().get(2); + return dx * dx + dy * dy + dz * dz <= this.radius * this.radius && this.camera.intersectsSection(cx, sy, cz); + } + + /** + * Converts an environment attribute colour to {@code 0xRRGGBB}. Accepts packed integer colours (alpha is ignored) + * as well as RGB vectors (components 0..1) and clamps everything so nothing can overflow into a neighbouring channel. + */ + private static int toRgb(Object raw, int fallback) { + if (raw instanceof Integer packed) { + return packed & 0xFFFFFF; + } + if (raw instanceof Vector3fc vector) { + return (channel(vector.x()) << 16) | (channel(vector.y()) << 8) | channel(vector.z()); + } + return fallback; + } + + private static int channel(float unit) { + return Mth.clamp((int) (unit * 255.0f + 0.5f), 0, 255); + } + + private static DataLayer copyLayer(LevelLightEngine engine, LightLayer layer, SectionPos pos) { + DataLayer data = engine.getLayerListener(layer).getDataLayerData(pos); + return data == null ? null : data.copy(); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index e1bbf2f..a1427c8 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "id": "msmp-vision-mod", + "id": "msmp-vision", "version": "${version}", "name": "MSMP Vision", "description": "Extends the Minecraft Server Management Protocol (MSMP) by providing additional functions for taking and rendering screenshots entirely on the server-side.", @@ -20,9 +20,7 @@ "depends": { "fabricloader": ">=${loader_version}", "fabric-api": "*", - "minecraft": "^${minecraft_version}", - "msmp-lib": "*", - "config-lib": "*" + "minecraft": "^${minecraft_version}" }, "custom": { "mc-publish": {