diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e69f2..1f8af1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,9 @@ # on their "no GPU adapter" branch; the test job prints that skip count in its log # instead of letting it pass unseen. GPU behaviour is verified on a developer machine. # -# Toolchain: the repository carries no rust-toolchain.toml and no rust-version, so the -# version the local gate runs used (1.97.1) is pinned here. +# Toolchain: the repository carries no rust-toolchain.toml; each crate's rust-version is the +# floor it builds on, not the version tested, so the version the local gate runs used +# (1.97.1) is pinned here. name: ci on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09c5828..2166c9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,22 @@ -# Lumenwright release: publishes lumenwright-kernel, then lumenwright, to crates.io, then tags -# v and creates the GitHub Release. lumenwright-cli is not published. +# Lumenwright release: publishes lumenwright-kernel, then lumenwright, then lumenwright-cli +# (each after the one it depends on is in the crates.io index), then tags v and +# creates the GitHub Release. # # Run it from the Actions tab (or `gh workflow run release.yml -f version=X.Y.Z`) on main. # Nothing is published, tagged or released until every refusal below has passed: # * the run is not on main; -# * the input is not a version, or differs from the version of either published crate; +# * the input is not a version, or differs from the version of any of the three crates, or +# a crate is marked publish = false; # * main's CI (ci.yml, on push) did not succeed at this commit; -# * the version of either crate is already on crates.io, or the tag v exists; +# * any of the three crates already has the version on crates.io, or the tag v +# exists; # * CHANGELOG.md has no dated section for the version (a section still marked Unreleased # is refused); # * the test suite (ci.yml, run again here) fails. # -# Secret: CARGO_REGISTRY_TOKEN, a crates.io API token with the publish-update scope for both -# crates (publish-new for a first release), stored as a repository secret. +# Secret: CARGO_REGISTRY_TOKEN, a crates.io API token with the publish-update scope for the +# three crates (publish-new for a crate's first release, lumenwright-cli's at 0.2.0), stored +# as a repository secret. name: release on: @@ -59,18 +63,18 @@ jobs: exit 1 fi meta=$(cargo metadata --no-deps --format-version 1) - for crate in lumenwright-kernel lumenwright; do + for crate in lumenwright-kernel lumenwright lumenwright-cli; do have=$(jq -r --arg n "$crate" '.packages[] | select(.name==$n) | .version' <<<"$meta") if [ "$have" != "$VERSION" ]; then echo "::error::refused: $crate is at '$have' in the workspace, the input is $VERSION" exit 1 fi + publish=$(jq -r --arg n "$crate" '.packages[] | select(.name==$n) | .publish | tostring' <<<"$meta") + if [ "$publish" != "null" ]; then + echo "::error::refused: $crate may not be published (its publish field reads $publish)" + exit 1 + fi done - cli=$(jq -r '.packages[] | select(.name=="lumenwright-cli") | .publish | tostring' <<<"$meta") - if [ "$cli" != "[]" ]; then - echo "::error::refused: lumenwright-cli must carry publish = false (its publish field reads $cli)" - exit 1 - fi - name: Refuse a commit whose CI on main did not succeed env: GH_TOKEN: ${{ github.token }} @@ -87,7 +91,7 @@ jobs: - name: Refuse a version already on crates.io, or a tag that exists run: | set -euo pipefail - for crate in lumenwright-kernel lumenwright; do + for crate in lumenwright-kernel lumenwright lumenwright-cli; do # The sparse index: a 404 is the only answer read as "absent". path="$(cut -c1-2 <<<"$crate")/$(cut -c3-4 <<<"$crate")/$crate" code=$(curl -sS -o index.txt -w '%{http_code}' "https://index.crates.io/$path") @@ -139,27 +143,30 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal - - name: Publish lumenwright-kernel + - name: Publish lumenwright-kernel, lumenwright and lumenwright-cli, each once the index serves the one before env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish --locked -p lumenwright-kernel - - name: Wait until the index serves lumenwright-kernel at the version run: | set -euo pipefail - for _ in $(seq 60); do - if curl -fsS https://index.crates.io/lu/me/lumenwright-kernel 2>/dev/null \ - | jq -e --arg v "$VERSION" 'select(.vers==$v)' >/dev/null; then - echo "lumenwright-kernel $VERSION is in the index" - exit 0 - fi - sleep 10 - done - echo "::error::lumenwright-kernel $VERSION did not reach the index within 10 minutes" - exit 1 - - name: Publish lumenwright - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish --locked -p lumenwright + wait_for() { + path="$(cut -c1-2 <<<"$1")/$(cut -c3-4 <<<"$1")/$1" + for _ in $(seq 60); do + if curl -fsS "https://index.crates.io/$path" 2>/dev/null \ + | jq -e --arg v "$VERSION" 'select(.vers==$v)' >/dev/null; then + echo "$1 $VERSION is in the index" + return 0 + fi + sleep 10 + done + echo "::error::$1 $VERSION did not reach the index within 10 minutes" + return 1 + } + cargo publish --locked -p lumenwright-kernel + wait_for lumenwright-kernel + cargo publish --locked -p lumenwright + wait_for lumenwright + cargo publish --locked -p lumenwright-cli + wait_for lumenwright-cli release: needs: publish diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 4a0b9ec..3003859 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -30,6 +30,23 @@ Code, algorithms and constants Lumenwright takes from other works, with their li per step (`Octree.enterBlock`), the grass block's overlay over its side (`GrassBlockModel.java`), and the unknown biome's climate (`Biomes.java`). +## Delvewright's engine + +* Source: , read at revision `0fd300da7`. +* Licence: GNU General Public License, version 3. The repository's `LICENSE` is the GPL-3.0 + text; its README states GPL-3.0-or-later and its `Cargo.toml` `GPL-3.0-only`. The ported + code is used under the GPL-3.0, which Lumenwright's GPL-3.0-or-later admits. +* Ported into HQ mode's game sky ([docs/research/game-sky.md](docs/research/game-sky.md); each + port names its engine file in a code comment): + * the Hable curve read brighter by the gain that shows mid-grey as the game's transfer does, + in closed form (`HableCurve::brightened`, `mid_grey_gain`, `tone_curve` in + `crates/delvec/src/compiler/view/scene.rs`), copied into `render/post.rs`; + * the method that lights a scene as the 1.21.11 client lights a tick: no sunlight, the + lightmap's colour for a cell open to the sky as the light (`lightmap_sky`, `sky_cell` in + `scene.rs`, transcribed again from the client's `lightmap.fsh` in `render/sky.rs`), and + the client's sky plus the least uniform veil, scaled, as that light's directions + (`sky_map` in `crates/delvec/src/compiler/view/sky_image.rs`). + ## The Preetham sky model * A. J. Preetham, P. Shirley and B. Smits, "A Practical Analytic Model for Daylight", @@ -45,5 +62,11 @@ Code, algorithms and constants Lumenwright takes from other works, with their li ## Twilight * The end of civil twilight, the sun's centre 6 degrees below the horizon, at which HQ - mode's sky reaches a night's light: the definition the US Naval Observatory publishes - (a fact, not a work; no licence applies). + mode's Chunky sky (`--sky chunky`) reaches a night's light: the definition the US Naval + Observatory publishes (a fact, not a work; no licence applies). + +## Mid-grey + +* Linear 0.18, the reflectance of the photographic grey card exposure is metered against + (Kodak Gray Card, publication R-27), the value the game's tone curve is anchored at (a fact, + not a work; no licence applies). diff --git a/CHANGELOG.md b/CHANGELOG.md index 294a5e8..c689dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to Lumenwright are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/). +## [0.2.0] - Unreleased + +### Added + +- HQ mode draws the game's own sky (`HqSkyModel::Game`, `--sky game`), lit as the 1.21.11 + client lights the tick, by the method Delvewright's engine lights its Chunky scenes with + (`docs/research/game-sky.md`): the sun casts no light; the sky casts the colour the + client's lightmap gives a cell open to it, from the jar's `visual/sky_light_factor` and + `visual/sky_light_color` (now in `EnvironmentState`), with the directions of the client's + sky; the lens sees the client's sky as quick mode draws it, its disc, fog, sunrise glow, + sun, moon and stars. The host draws the sky into two equirectangular maps that every + backend reads (`Params::sky_map`; `integrator::sky_lens`, `sky_light`). A jar without + environment attributes is refused under it. +- The game look's emitter intensity, `GAME_EMITTER_INTENSITY` = 137.9628 (13 × 10.6125), + authored by Lumenwright: fitted so that the Treehouse Camp's lantern-lit pixels at dusk + read as Delvewright's engine's Chunky render of the frame does. `--emitter-intensity` + takes any value over it. +- `--tonemap filmic-mid-grey` (`Tonemap::FilmicMidGrey`, `post::game_tone_curve`): Chunky's + Hable curve read 1.463761 times brighter, so linear 0.18 shows at 0.18^(1/2.2) as the + game's transfer shows it. + +### Changed + +- HQ mode's default sky goes by the time of day (`SkyLook::ByTime`, `--sky auto`): Chunky's + Preetham sky and sun on the client's day plateau (ticks 730 to 11270, where + `visual/sky_light_factor` is 1), the game's sky on the night plateau, and between them one + render mixing the two by where the factor stands between its night and day values, with no + jump from one tick to the next. `--sky chunky` and `--sky game` force either look. + `HqSkyModel` is a struct (`look`, `render_distance`, `sun_intensity`, `sky_intensity`); + `Sky::Vanilla` carries it in place of `sun_intensity` and `sky_intensity`. + `--sun-intensity` and `--sky-intensity` are refused only under `--sky game`. +- `HqOptions::tonemap` and `emitter_intensity` are `Option`s: unstated, each look takes its + own (`gamma` and 13 for Chunky's, `filmic-mid-grey` and `GAME_EMITTER_INTENSITY` for the + game's), mixed by the day weight. +- `Params` is 26 vec4s (416 bytes): `sky_map` gives the sizes of the sky's maps. A sun of + colour 0 draws no next-event sample. +- `Tonemap::Filmic` is evaluated as Chunky's `processPixel` evaluates it (the parameter + products in float, the rest in double) through the one `HableCurve`. +- The HQ timing stage `sky cache` is `sky`. +- `lumenwright-cli` is published to crates.io with the other two crates (`cargo install + lumenwright-cli`); the release workflow publishes `lumenwright-kernel`, `lumenwright` and + `lumenwright-cli` in that order and refuses unless all three are absent at the version. +- `lumenwright-kernel` declares `rust-version` 1.88, the first release it builds on (its `if let` + chains); 0.1.0 declared 1.85, on which it does not compile. + ## [0.1.0] - 2026-10-20 The first release. The `lumenwright` library and its kernel `lumenwright-kernel` are published on diff --git a/Cargo.lock b/Cargo.lock index b70f2a9..2603427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,7 +791,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lumenwright" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "bytemuck", @@ -813,7 +813,7 @@ dependencies = [ [[package]] name = "lumenwright-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -833,7 +833,7 @@ dependencies = [ [[package]] name = "lumenwright-kernel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bytemuck", "libm", diff --git a/Cargo.toml b/Cargo.toml index 7d7f220..9c1cd44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["crates/lumenwright-ptx"] [workspace.package] edition = "2024" -version = "0.1.0" +version = "0.2.0" license = "GPL-3.0-or-later" repository = "https://github.com/stellarfeline/lumenwright" diff --git a/README.md b/README.md index 549111d..782867a 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,15 @@ An offline, headless renderer for Minecraft Java Edition worlds. You give it a world save and a camera, and it draws the blocks and entities. It comes as two crates: the `lumenwright` command (crate `lumenwright-cli`) writes a PNG, and the `lumenwright` library returns the -pixels to your program (see [Library](#library)). It has two looks: +pixels to your program (see [Library](docs/library.md#library)). It has two looks: * **quick**: rasterized to look like the vanilla Java client, with no ray tracing. -* **hq**: a path tracer ported from Chunky's, with its Preetham sky, its sun and soft - shadows, its emitters, its display gamma, and refractive water (what was ported and how - the result compares with Chunky: [docs/research/chunky-parity.md](docs/research/chunky-parity.md)). +* **hq**: a path tracer ported from Chunky's, with its emitters, its display gamma and + refractive water. By default it goes by the time of day: by day Chunky's Preetham sky and + a sun that lights and shadows, at dusk and night the game's own sky, lit as the client + lights the hour ([docs/research/game-sky.md](docs/research/game-sky.md)); `--sky chunky` + and `--sky game` force either (what was ported and how the result compares with Chunky: + [docs/research/chunky-parity.md](docs/research/chunky-parity.md)). Both modes run on several native backends: **CPU** (all cores), **Metal**, **Vulkan** and **DX12** (through wgpu), and **CUDA** (the Rust path-tracing kernel compiled to PTX). @@ -17,7 +20,7 @@ Both modes run on several native backends: **CPU** (all cores), **Metal**, **Vul ![The Treehouse Camp by day, path traced](docs/renders/treehouse-camp-day-hq.png) -*hq · `--time 4000` · 4096 spp · 8 bounces · CUDA on an NVIDIA GeForce RTX 4070 Ti SUPER · 92 s end to end* +*hq · `--time 4000` · 4096 spp · 8 bounces · CUDA on an NVIDIA GeForce RTX 4070 Ti SUPER · 91 s end to end* | | quick (vanilla look) | hq (path traced) | |---|---|---| @@ -31,55 +34,7 @@ joined by rope bridges and ladders and lit by lanterns. The top rows look at the from the east-north-east; the bottom rows stand above the Long Bridge, looking south. All eight are 1280×720, rendered from a save written by the map's own server. -HQ mode runs with its defaults, which are Chunky's: the Preetham sky, a sun of intensity 1.25, -lanterns and campfires as emitters at intensity 13 with one emitter sampled per diffuse bounce, -and the display gamma 2.2. By day the sun and the sky light the camp, and each lantern lights -the post it stands on. - -At `--time 12800` the sun stands just below the horizon. Quick mode draws the client's sunset: -the sun disc, the orange glow and the light the game stored in the save. HQ mode draws -Chunky's sunset: the Preetham sky at its full sunset light, with the client's sun texture drawn -at the sun's place. A sun below the horizon casts no light, so the camp is lit by that sky and -by its lanterns, which glow and light little more than the block they stand on (see -[docs/research/chunky-parity.md](docs/research/chunky-parity.md)). - -Each image's command, run from the repository root with the save from -[Get the camp](#get-the-camp) in `the-treehouse-camp`. The HQ commands need the CUDA build -(see [Backends](#backends)); on another backend the same flags give the same image within the -parity stated under [Measured](#measured-on-an-m2-pro-10-cpu-cores-16-gpu-cores). - -```sh -# The whole camp -target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ - --time 4000 --backend metal -o treehouse-camp-day-quick.png -target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ - --time 4000 --backend cuda --mode hq --spp 4096 --max-bounces 8 -o treehouse-camp-day-hq.png -target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ - --time 12800 --backend metal -o treehouse-camp-dusk-quick.png -target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ - --time 12800 --backend cuda --mode hq --spp 16384 --max-bounces 8 -o treehouse-camp-dusk-hq.png - -# The Long Bridge -target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ - --time 4000 --backend metal -o treehouse-bridge-day-quick.png -target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ - --time 4000 --backend cuda --mode hq --spp 4096 --max-bounces 8 -o treehouse-bridge-day-hq.png -target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ - --time 12800 --backend metal -o treehouse-bridge-dusk-quick.png -target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ - --time 12800 --backend cuda --mode hq --spp 16384 --max-bounces 8 -o treehouse-bridge-dusk-hq.png -``` - -| image | Apple M2 Pro, Metal (end to end) | RTX 4070 Ti SUPER, CUDA (end to end / in the kernel) | -|---|---|---| -| camp, quick, day / dusk | 0.27 s / 0.27 s | | -| camp, hq, day (4096 spp) / dusk (16384 spp) | | 92 s / 333 s (90 s / 331 s) | -| bridge, quick, day / dusk | 0.23 s / 0.23 s | | -| bridge, hq, day (4096 spp) / dusk (16384 spp) | | 123 s / 447 s (121 s / 444 s) | - -The quick times are the second of two runs. The 4070 Ti SUPER runs under WSL2 on a 16-thread PC. -Each HQ image rendered twice with the same command on the CUDA backend gave byte-identical -PNGs, as each quick image did on Metal. +Each image's command, its time and how HQ mode lights it: [docs/renders.md](docs/renders.md). ### Image licence @@ -97,172 +52,42 @@ The requirements, design decisions and what is in or out of scope are in ## Install -The command, from this repository (it is not on crates.io): +The command, from crates.io (from 0.2.0): ```sh -cargo install --locked --git https://github.com/stellarfeline/lumenwright --tag v0.1.0 lumenwright-cli +cargo install lumenwright-cli lumenwright backends # the backends this machine can draw with ``` -You need Rust 1.90 or newer. Installing the command also compiles a little C (the TLS -library its jar downloader uses), so it needs a C compiler; the library alone compiles none. +Rust versions, each checked with `cargo + check`: the command (`lumenwright-cli`) and +the library (`lumenwright`) build on Rust 1.90 and not on 1.89; +the kernel (`lumenwright-kernel`) builds on 1.88 and not on 1.87. Installing the command also +compiles a little C (`ring`, for the TLS its jar downloader uses), so it needs a C compiler; the +library, with or without its default features, compiles none. What each backend needs to run: * **CPU**: any Linux, macOS or Windows machine. It uses every core. * **Metal, Vulkan, DX12**: a GPU and its driver: Metal on macOS, Vulkan on Linux or Windows, - DX12 on Windows. wgpu's environment flags apply; under WSL2 the GPU is reached through - Mesa's `dzn` Vulkan driver, which wgpu lists only with - `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`. + DX12 on Windows. wgpu's environment flags apply. Under WSL2 on the PC the benchmark runs on, + wgpu finds only a software Vulkan adapter, with or without + `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`; use CUDA there. * **CUDA**: an NVIDIA GPU and its driver, and the kernel's PTX built from a clone of this - repository (see [Backends](#backends)). + repository (see [Backends](docs/backends.md#backends)). Every render also needs the Minecraft Java Edition client jar, which Lumenwright reads its textures and models from. The command finds it or downloads it from Mojang (see -[Textures](#textures-the-chunky-approach)); the library takes its path. - -### Using the crate - -The library is on crates.io as `lumenwright`, with its kernel `lumenwright-kernel`: - -```toml -[dependencies] -lumenwright = "0.1.0" # CPU, Metal, Vulkan, DX12 -# lumenwright = { version = "0.1.0", default-features = false } # CPU only -# lumenwright = { version = "0.1.0", default-features = false, features = ["gpu"] } -``` - -The `cuda` feature embeds the kernel's PTX, which `scripts/build-ptx.sh` builds in a clone of -this repository (nightly Rust and the `nvptx64-nvidia-cuda` target). In a clone it is found in -`target/ptx/`; from crates.io, set `LUMENWRIGHT_PTX` to the file the script wrote. Without it -the build stops and says so; nothing is downloaded. See [Library](#library) for the API. +[Textures](docs/textures.md#textures-the-chunky-approach)); the library takes its path. ## Quick start -```sh -cargo build --release -``` - -### Get the camp - -The camp ships as a container image that runs the map's own server. The server builds the -camp over its first ticks. These commands wait for it to finish, then save the world and copy -it out. They need Docker. `EULA=TRUE` accepts [Mojang's EULA](https://aka.ms/MinecraftEULA) -for the server inside the container. - -```sh -docker run -d --name treehouse-camp -e EULA=TRUE ghcr.io/stellarfeline/delve-the-treehouse-camp:v1.0.1 -# The camp is stamped by its datapack over the first ticks; wait until it says so. -until docker exec treehouse-camp rcon-cli scoreboard players get '#placed' dw.sys 2>/dev/null | grep -q 'has 1'; do sleep 5; done -docker exec treehouse-camp rcon-cli save-all flush -docker stop treehouse-camp -docker cp treehouse-camp:/data/world the-treehouse-camp -docker rm treehouse-camp -``` - -The image is release v1.0.1 of the camp, built by `delvec` 1.12.0 from -`campaigns/the-treehouse-camp/` in -[stellarfeline/delvewright-campaigns](https://github.com/stellarfeline/delvewright-campaigns) -at the release's tag. The campaign's `GENERATION.md` there records how it was built and -checked. The save is written by a running server, so it is not byte-identical from one run -to the next: the same frame of two saves from two server runs differed in 257 of its 921,600 pixels. - -### Render +Render a save from an explicit camera, path traced under the game's sky at dusk: ```sh -# Inspect a save -target/release/lumenwright info the-treehouse-camp --entities - -# List the block entities (and sign text) in a box of x,z block coordinates -target/release/lumenwright info the-treehouse-camp --block-entities=0,0,112,112 - -# Vanilla-look render from the save's spawn, at the save's own time of day -target/release/lumenwright render -w the-treehouse-camp -o quick.png - -# Chunky-look render from an explicit camera -target/release/lumenwright render -w the-treehouse-camp --mode hq --spp 256 \ - --camera 140,118,14 --look-at 56,82,58 --sun-altitude 35 --sun-azimuth 120 -o hq.png - -# Generated worlds, no download: a small showcase (terrain, cottage, lake, mobs) -target/release/lumenwright demo-world showcase -target/release/lumenwright render -w showcase --mode hq -o showcase.png - -# One of every block entity Lumenwright draws (the render below) -target/release/lumenwright demo-world --block-entities gallery -target/release/lumenwright render -w gallery \ - --camera 9.5,70.5,11.5 --look-at 9.5,65.4,-2 --fov 60 -o gallery.png +lumenwright render -w path/to/save --camera 140,118,14 --look-at 56,82,58 --time 12800 --mode hq --spp 256 -o hq.png ``` -`lumenwright backends` lists the backends available on the current machine. - -## Textures: the Chunky approach - -Mojang's assets are not redistributable, so Lumenwright ships none. Like Chunky, it reads -textures, blockstates and block models straight out of the official client jar. The jar is -resolved in this order: - -1. `--minecraft-jar ` -2. A launcher's copy for the world's version: the vanilla launcher's - `versions//.jar`, Prism Launcher's library, or Lumenwright's own cache -3. Chunky's `~/.chunky/resources/minecraft.jar`, if its version matches -4. A download from Mojang's version manifest (`piston-meta.mojang.com`), SHA-1 verified, into - the cache (`~/Library/Caches/lumenwright` or `$XDG_CACHE_HOME/lumenwright`). `--offline` - disables this; `lumenwright assets fetch ` downloads ahead of time. - -This resolution and the downloader belong to the command. The library takes the jar as a path. - -The jar's own `font/include/unifont.json` lists no providers: the client reads the Unifont that -draws CJK and every other character no bitmap font covers from the launcher's asset index. The -command fetches it the same way, in this order: `--unifont ` (a directory holding -`minecraft/font/include/unifont.json` and the hex zip it names, laid out like the index's keys), -then the cache (`/unifont/`, every file checked against the SHA-1 its lock -records), then a download through the version's asset index, the index and every file checked by -SHA-1 and size. `--offline` forbids the download and refuses, naming what is missing, when -neither of the first two has the files. `lumenwright assets fetch-unifont ` -downloads ahead of time. The library takes the directory as `Request::unifont` and does the same -checks on it without any network. - -`--resource-pack` (zip or directory, repeatable) layers packs on top of the vanilla assets. - -## Command line (render) - -| flag | default | meaning | -|---|---|---| -| `-w, --world` | required | save directory (contains `level.dat`) | -| `-o, --output` | render.png | the PNG to write | -| `--mode quick\|hq` | quick | vanilla raster or path tracing | -| `--backend auto\|cpu\|gpu\|metal\|vulkan\|dx12\|cuda` | auto | `auto` uses the GPU if one is available, otherwise the CPU | -| `--camera x,y,z` | player eye, else spawn | eye position | -| `--yaw`, `--pitch` | player's | Minecraft convention: yaw 0 = south, 90 = west; pitch > 0 looks down | -| `--look-at x,y,z` | | overrides yaw and pitch | -| `--fov` | 70 | vertical field of view in degrees | -| `--width`, `--height` | 1280×720 | output size | -| `--render-distance` | 8 | chunk radius loaded around the camera | -| `--dimension` | overworld | `overworld`, `nether`, `end` | -| `--time` | world time | ticks: 0 = sunrise, 6000 = noon | -| `--sun-altitude`, `--sun-azimuth` | from time, 135 | degrees (azimuth 0 = north, 90 = east); an altitude overrides `--time` for the sun | -| `--spp`, `--max-bounces`, `--seed` | 64, 4, 0 | HQ sampling; the same seed gives the same image | -| `--exposure`, `--tonemap gamma\|aces\|filmic` | 1.0, gamma | HQ post-processing, Chunky's filters (each ends in the display gamma 2.2) | -| `--sun-intensity`, `--sky-intensity` | 1.25, 1 | HQ lighting: Chunky's sun intensity (irradiance intensity^2.2) and sky light | -| `--emitter-intensity` | 13 | HQ: Chunky's emitter intensity; an emitting surface emits its texel colour squared times its block's emittance times this (0 turns emitters off) | -| `--emitter-sampling none\|one\|one-block\|all`, `--prevent-normal-emitter` | one | HQ: Chunky's emitter sampling strategies, and its "prevent normal emitter with sampling" | -| `--ssaa N` | 1 | quick-mode supersampling | -| `--mipmap-levels N` | 4 | quick-mode mip levels for block textures, 0 (off) to 4, as the client's option | -| `--brightness`, `--flat-lighting` | 0.5 | vanilla brightness slider, and smooth lighting off | -| `--hdr-output out.pfm` | | also write the linear HDR image (HQ) | -| `--no-entities`, `--players`, `--unknown-entities box\|skip` | | entity options | -| `--no-block-entities` | | leave out block entities drawn by Java code | -| `--minecraft-jar`, `--mc-version`, `--unifont`, `--offline`, `--resource-pack` | | the client jar, the Unifont and packs (see [Textures](#textures-the-chunky-approach)) | -| `--threads`, `--log` | all cores, info | CPU worker threads and log level, for every subcommand | - -## Library - -`lumenwright::render(&Request)` draws one frame and returns a `Rendered`. The request names -every input: the world (a save directory, or a `World` you opened) and the inclusive chunk -rectangle to load, the client jar and resource packs as paths, the camera, the mode, the -backend, the size, the quick and HQ options, which entities to draw, and the sky. The sky is -either vanilla's at a time of day (`Sky::Vanilla`) or the colours and sun you state -(`Sky::Stated`). +From Rust, with `lumenwright = "0.2.0"` in `[dependencies]`: ```rust use std::path::Path; @@ -277,6 +102,7 @@ let frame = lumenwright::render(&Request { world: WorldSource::Dir { path: Path::new("saves/MyWorld"), dimension: Dimension::Overworld }, chunks: ChunkRect::around(camera.pos, 8), jar: Path::new("minecraft-1.21.11-client.jar"), + unifont: None, resource_packs: &[], camera, mode: Mode::Quick, @@ -288,287 +114,22 @@ let frame = lumenwright::render(&Request { hq: HqOptions::default(), sky: Sky::default(), })?; -// frame.rgba: RGBA8, top row first. frame.backend, frame.backend_fallback, -// frame.timings, frame.entity_stats, frame.block_entity_stats: what was drawn and how. -``` - -`render` does not look for a jar, use the network, write files or print. When it refuses a -request before drawing (a missing jar, an empty image, no chunks in the rectangle), the -error holds a `RequestError` you can match with `err.downcast_ref::()`. On the -CPU backend the same request gives the same bytes whatever the thread count. - -The library crate holds no command-line code, no jar resolver and no downloader. Its default -feature, `gpu`, adds the Metal, Vulkan and DX12 backends; `cuda` adds the CUDA backend. With -the default features it compiles no C code, so it cross-checks for Linux, macOS and Windows -targets without a cross C compiler. The `lumenwright render` command calls the same -function, so the command and the library draw the same pixels. - -## Architecture - -``` -crates/lumenwright/ the library - world/ Anvil regions, paletted chunk sections, light, biomes, entities, level.dat - assets/ blockstate variants/multipart, model baking, atlas, biome tints, font, lang - scene/ material-resolved quads; quick mesh (culling, smooth light + AO); - HQ brick grid (16³ bricks, u16 model instances, entity overlay cells); entity models - request.rs the entry point: Request -> render() -> Rendered - render/ Backend trait - cpu/ tile-binned software rasterizer + lumenwright-kernel on rayon - gpu/ wgpu: quick.wgsl (raster pipeline), pathtrace.wgsl (compute port of the kernel) - cuda.rs lumenwright-kernel compiled to PTX, launched via cudarc -crates/lumenwright-cli/ the `lumenwright` command, over the library's public API - main.rs subcommands, logging - job.rs render: flags -> Request -> render() -> PNG/PFM - jar.rs client-jar resolution and download - output.rs PNG and PFM writers - values.rs flag values for the library's option enums - fixture.rs generated worlds for demo-world and the tests -crates/lumenwright-kernel/ #![no_std] path tracer shared by CPU and CUDA (same source) -crates/lumenwright-ptx/ CUDA entry point (builds for nvptx64-nvidia-cuda) -``` - -Both modes share one scene build. Quick mode turns visible quads into a triangle mesh, the -way the vanilla client does. HQ mode instances block models inside a sparse two-level voxel -grid: empty bricks are skipped and fully buried bricks are collapsed, so a ray needs one -traversal loop and no BVH. Entities go into sparse overlay cells of the same grid. - -Backend parity is by construction. The CPU and CUDA backends run the same Rust kernel. The -WGSL kernel mirrors it line by line and uses the same RNG, so CPU and Metal draw the same -random numbers for each pixel and sample. Builds are deterministic: identical inputs give -bit-identical images. - -Quick mode samples block textures as the 1.21.11 client does: each sprite of the client's -block atlas (`atlases/blocks.json`) gets a mip chain built like `MipmapGenerator` builds it -(a linear-light 2×2 mean; cutout textures keep their alpha-test coverage; the `.mcmeta` -`mipmap_strategy` and `alpha_cutoff_bias` apply), padded and aligned like the client's -stitcher so no level bleeds into a neighbour, and read through `terrain.fsh`'s -`sampleNearest` with trilinear, 4× anisotropic filtering. Pixel art stays sharp up close and -distant terrain does not shimmer. Entities, items and text are read nearest, as the client -reads its other atlases. The quick sky is the client's `SkyRenderer`: the fogged sky disc, the -sunrise and sunset glow, the sun, this night's moon phase and the 1500 stars, with every -colour and angle read from the jar's environment attributes and day timeline (dimension, -biome at the camera, time of day). The world fog adds the client's environmental fog to the -render-distance fog. HQ mode keeps its own sky model; both modes place the sun at the -timeline's `sun_angle`. - -Mob geometry lives in Java code in vanilla, not in assets, so it ships as data in -[`crates/lumenwright/assets/entity_models.json`](crates/lumenwright/assets/entity_models.json): -57 mobs in vanilla's own part, cube and box-UV terms, transcribed from 1.21.11. Adding a mob -needs no Rust changes. Block displays, paintings and mannequin or player skins are built in -code. Anything else is drawn as a box the size of its hitbox. - -Text displays are drawn in the jar's default font (`assets/minecraft/font/default.json` and -the bitmap providers it names), placed as the client places them: the `text` component with -its colours, bold, italic and `translate` keys, split into lines no wider than `line_width`, -`alignment`, the `background` colour or `default_background`, `shadow`, `billboard` and -`transformation`. A `see_through` display is drawn over whatever stands in front of it. A text -display is seen from the front only, as in the game. `text_opacity` and `brightness` are not -read. A character no bitmap provider covers is drawn from the Unifont (see -[Textures](#textures-the-chunky-approach)), cut and spaced as the client's unihex provider cuts -and spaces it; without a Unifont directory it is left out. - -![entities](docs/renders/entity-lineup.png) - -Block entities that vanilla draws in Java code use the same format, in -[`crates/lumenwright/assets/block_entity_models.json`](crates/lumenwright/assets/block_entity_models.json), -each model naming the 1.21.11 client class it is transcribed from. Their data comes from the -chunk's `block_entities`: - -* signs and hanging signs, with front and back text in the vanilla bitmap font - (`assets/minecraft/font`), dye colour, glowing text and its outline. A `translate` line - resolves through the `en_us` language files of the jar and the resource packs; -* bells, the lectern's book and the enchanting table's book; -* chests: single, double, trapped, ender and copper; -* banners, with their pattern layers composited in their dye colours; -* decorated pots with their sherds; -* skulls and heads. A player head's skin is the profile's `texture` asset when a resource - pack provides it, otherwise the default skin for the profile's UUID. Skins are never - downloaded. - -![block entities](docs/renders/block-entities-quick-metal.png) -![signs](docs/renders/block-entities-signs.png) - -Items are drawn through the jar's item model definitions (`assets/minecraft/items/*.json`, -the format of 1.21.4 and later), resolved against the stack's components and where it is -drawn. A model with elements is baked as a block model; a flat item is extruded from its -sprite, a front and a back face and one face for every open texel edge, as the client does. -The model's display transform for its context poses it: - -* dropped items (`ground`), bobbing and turned by a phase taken from the entity's UUID, as - a cluster of up to five copies for a large stack; -* item frame and glow item frame contents (`fixed`), turned by `ItemRotation`; -* item displays, in their `item_display` context, `transformation` and `billboard`; -* items in the hands (`thirdperson_righthand`, `thirdperson_lefthand`) and non-armour items - on the head (`head`) of armour stands, mannequins, players and humanoid mobs. Armour - stands take their `Pose` and `ShowArms`. - -Worn armour is drawn from the jar's equipment assets (`assets/minecraft/equipment/*.json`) -on the humanoid armour meshes, dyed leather included. The shield is drawn from data in -[`crates/lumenwright/assets/item_models.json`](crates/lumenwright/assets/item_models.json), -with the armour meshes, each citing its 1.21.11 client class. `EntityStats` counts the -stacks drawn and not drawn by item id (`items_rendered`, `items_skipped`). - -![items](docs/renders/items-quick.png) - -```sh -target/release/lumenwright demo-world --items /tmp/items -target/release/lumenwright render -w /tmp/items \ - --camera 7.5,67.6,9.5 --look-at 7.5,66.0,2.5 --fov 60 -``` - -## Backends - -| backend | quick | hq | tested here (Apple M2 Pro) | -|---|---|---|---| -| cpu | software rasterizer | Rust kernel on rayon | yes | -| metal | wgpu render pipeline | WGSL compute | yes, matches the CPU (see below) | -| vulkan / dx12 | same wgpu code | same WGSL | no, but these are the same code paths as Metal | -| cuda | falls back to the CPU rasterizer | Rust kernel as PTX | not on the M2 Pro; on an RTX 4070 Ti SUPER (WSL2) HQ matches the CPU | - -The CUDA backend needs the PTX built first: - -```sh -scripts/build-ptx.sh # rustup target nvptx64-nvidia-cuda + llvm-bitcode-linker -cargo build --release -p lumenwright-cli --features cuda -lumenwright render ... --mode hq --backend cuda -``` - -`libcuda` is loaded at runtime, so CUDA builds work on machines without the CUDA toolkit. - -`scripts/build-ptx.sh` refuses a kernel whose PTX carries an f64 instruction (consumer NVIDIA -GPUs run f64 at a small fraction of f32 speed) or calls a function other than a panic. The -path tracer is inlined whole into the kernel because, with its functions called, the driver's -optimizing PTX compiler (driver 616.92) produced wrong pixels or launches that never finished, -while the same PTX compiled without optimization matched the CPU. - -On WSL2 the driver library is `/usr/lib/wsl/lib/libcuda.so.1`. A Linux NVIDIA driver package -installed inside WSL (Ubuntu's `libnvidia-compute-*`, pulled in by `nvidia-utils-*`) puts a -native `libcuda.so` in `/usr/lib/x86_64-linux-gnu`, which the backend loads first and which -finds no device (`cuInit` returns 100, `CUDA_ERROR_NO_DEVICE`). Run with -`LD_LIBRARY_PATH=/usr/lib/wsl/lib`, or remove that package. - -An HQ frame logs its kernel time by CUDA events beside the wall clock (`--log info`). - -## Testing - -```sh -cargo test --workspace --release +// frame.rgba: RGBA8, top row first. ``` -* **Unit tests** cover region and NBT decoding, the LZ4 framing, palette unpacking, multipart - rules, model baking orientation and UVs, the camera convention, sky and day cycle, the - environment attribute layering, mip chains (the client's lookup tables and cutout - coverage), quick-mode texture sampling, the lightmap, the tonemapper, and entity box-UV - and orientation. -* **Kernel tests** use hand-built buffers to check brick skipping, uniform bricks, entity - overlay cells, cutout alpha, one-sided faces met far along a ray, coplanar faces, the - sky, Chunky's emitter sampling against its expectation, and pinned RNG values; the kernel's - `f32` `ln`, `acos` and gamma decoding are held to bounds against `f64`. A unit test holds - the kernel's sky to Chunky's Preetham sky transcribed in `f64`. -* **Integration tests** (in `lumenwright-cli`, which owns the fixture worlds) write fixture - worlds and render them end to end. They check terrain - colours seen from above, entity coverage, block entities in both modes, sign text, items - in both modes (every holder kind, each item's spot against the same frame with the - holders empty), that every item definition in the jar resolves, HQ sanity, CPU-vs-GPU - parity in both modes (showcase, block-entity gallery and item gallery) and CPU-vs-CUDA - parity in HQ mode, determinism, and the CLI. The library tests check that the CLI's PNG - and the library's pixels are byte-equal in both modes, that a missing jar is refused by - name, that a stated sky is drawn as given, that the vanilla sky's colour at noon, dusk and - midnight matches the jar's day timeline within 2/255, and that CPU pixels are - bit-identical on 1, 3 and 8 threads (showcase and item gallery). A far-plane test measures - the high-frequency energy and the change under a sub-pixel camera move with and without - mipmaps. Tests that need the - client jar or a GPU skip with a message when it is missing. `LUMENWRIGHT_MC_JAR` points the tests at a jar. - -## Measured on an M2 Pro (10 CPU cores, 16 GPU cores) - -| scene | mode / backend | time | -|---|---|---| -| The Treehouse Camp, whole-camp view at `--time 4000`, 625 chunks loaded, 1920×1080 | quick / metal | 0.28 s end to end (48 ms raster, 982k triangles) | -| same | quick / cpu | 0.36 s end to end (139 ms raster) | -| same, 1280×720, 32 spp, 4 bounces | hq / metal | 4.1 s end to end (3.9 s path tracing) | -| same, 1280×720, 16 spp, 4 bounces | hq / cpu | 6.1 s end to end (5.9 s path tracing) | -| showcase (`demo-world`, its default camera), 1280×720, 256 spp | hq / metal | 14.7 s | - -Parity on the whole-camp view at `--time 4000`, CPU vs Metal: in quick mode at 1920×1080, 0.22% -of pixels differ by more than 8/255; in HQ mode at 1280×720 and 32 spp, 0.001% do (12 pixels). - -## HQ mode and Chunky on one frame - -The whole-camp view of The Treehouse Camp at 1280×720, 250 samples per pixel, four bounces, with Chunky's default sky, -sun, emitters and tone map (the full method: [docs/research/chunky-parity.md](docs/research/chunky-parity.md)). -Against Chunky's frame converged at 4000 samples, Lumenwright's 250-sample frame differs by a -mean of 1.66/255 (SSIM 0.959), Chunky's own 250-sample frame by 1.99/255 (SSIM 0.958). At -dusk (`--time 12800`, the sun just below the horizon) the same view differs by 1.21/255 -(SSIM 0.955), Chunky's own 250-sample frame by 1.28/255 (SSIM 0.954). - -| renderer | machine | path tracing, median of 3 | whole command, median of 3 | vs Chunky | -|---|---|---|---|---| -| Chunky 2.5.0-SNAPSHOT.474 (CPU) | M2 Pro | 116.71 s | 122.29 s | 1× | -| Lumenwright, Metal | M2 Pro | 31.98 s | 32.32 s | 3.6× / 3.8× | -| Lumenwright, CUDA | RTX 4070 Ti SUPER | 6.14 s | 6.70 s | different machine | - -```sh -# Lumenwright (--backend metal on the M2 Pro, cuda on the 4070) -target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ - --time 4000 --sun-altitude 45 --sun-azimuth 135 --mode hq --spp 250 --max-bounces 4 --backend metal -o lw.png -# Chunky: the pinned core on its own classpath, as Delvewright's validation/chunky.sh runs it, without its denoise step -mkdir -p scenes && cp docs/bench/treehouse-camp-bench.json scenes/ -java -Djava.awt.headless=true -Dchunky.home=$HOME/.chunky -cp "" se.llbit.chunky.main.Chunky \ - -scene-dir scenes -render treehouse-camp-bench -f -``` - -The Chunky scene ([`docs/bench/treehouse-camp-bench.json`](docs/bench/treehouse-camp-bench.json); its world path is read from the working directory) states the same 625 chunks, the camera -at 140, 118, 14 with Chunky's yaw 0.483 rad and pitch −1.208 rad (looking at 56, 82, 58), a 60° -vertical field of view, `sppTarget` 250, `rayDepth` 5 (four bounces after the first hit, -read off the pinned core's `PathTracer`), `emittersEnabled`, `emitterSamplingStrategy` `ONE`, -and the sun at altitude π/4 and Chunky azimuth π/4 (towards +X +Z). - -## Measured on an RTX 4070 Ti SUPER (driver 616.92, WSL2) - -| scene | mode / backend | time | -|---|---|---| -| showcase, 1280×720, 64 spp, 4 bounces | hq / cuda | 0.85 s in the kernel (CUDA events), 1.43 s end to end | - -The same frame on CUDA and on the CPU: no pixel differs by more than 0.05 in linear radiance. - -## Known limitations (v0.1) - -See [docs/SCOPE.md §3](docs/SCOPE.md). The main gaps: -* Beds, shulker boxes and the conduit are not drawn. Chests are drawn closed and bells at - rest; sign text is drawn without italic, underline or obfuscation. -* Items that vanilla draws in Java code are not drawn, the shield excepted: chests, beds, - banners, heads, shulker boxes, decorated pots, the conduit and copper golem statues. They - are counted in `items_skipped`. A filled map in a frame shows the map item, not its - picture; armour trims and the elytra are not drawn. -* Entity layers that need a second texture are missing (sheep wool, glowing eyes). -* Smooth fluid surfaces, clouds, weather and biome blending are not implemented. In quick - mode the End's sky box is drawn as its fog colour, the dark disc vanilla draws below the - horizon for a camera under sea level is not drawn, and the lightmap's daylight still - follows the pre-1.21.11 day curve. -* HQ mode lights emitters as Chunky does, and Chunky's emitter sampling is not a light - transport estimate: it picks one emitter near the surface without dividing by the pick's - probability, so a surface among many emitters gets the average of their light rather - than the sum, and an emitter farther than 10 to 19 blocks along an axis (by where the grid - cells fall) is never sampled; it lights a surface only through bounced paths that meet it. -* Not yet ported from Chunky: biome blending, candle and campfire flame particles, its - fancier translucency, water shading, metals and its Nishita sky - ([docs/research/chunky-parity.md](docs/research/chunky-parity.md)). -* Chunky has no time of day. HQ mode keeps Chunky's sky while any part of the sun's disc is - above the horizon; below it, the sky falls to a night's 3% at the end of civil twilight - (the sun 6° below the horizon), and the sun gives no light from below the horizon. Those - two rules are Lumenwright's own. +The Treehouse Camp save, more commands and generated worlds: [docs/renders.md](docs/renders.md). -## Releasing +## Documentation -Releases are published by CI only, never from a workstation. The `release` workflow -(`.github/workflows/release.yml`) is run by hand on `main` with the version to release. It -refuses unless that version is the workspace version, `main`'s CI succeeded at the commit, -neither crate has that version on crates.io, no tag `v` exists, and `CHANGELOG.md` -has a dated `## [] - YYYY-MM-DD` section. It then runs the tests, publishes -`lumenwright-kernel` and then `lumenwright` with the repository secret -`CARGO_REGISTRY_TOKEN` (a crates.io API token for both crates), and tags `v` with a -GitHub Release whose notes are that changelog section. `lumenwright-cli` is not published. +* [The Treehouse Camp renders](docs/renders.md): each image's command and time, how HQ mode lights it, getting the camp, more commands. +* [Benchmark](docs/benchmark.md): Lumenwright against Chunky on one machine, the backends against each other, the cost of samples; design, tables and instruments. +* [The game's sky](docs/research/game-sky.md) and [HQ mode against Chunky](docs/research/chunky-parity.md): what HQ mode ports and how it compares. +* [Command line](docs/cli.md): every `render` flag. +* [Library](docs/library.md): using the crate and the `render` API. +* [Textures](docs/textures.md): the client jar, the Unifont and resource packs. +* [Architecture](docs/architecture.md): the crates, the scene, and what each mode draws. +* [Backends](docs/backends.md): CPU, Metal, Vulkan, DX12 and CUDA, and building the PTX. +* [Testing](docs/testing.md), [Known limitations](docs/limitations.md), [Releasing](docs/releasing.md), [Scope](docs/SCOPE.md). ## License diff --git a/crates/lumenwright-cli/Cargo.toml b/crates/lumenwright-cli/Cargo.toml index 001c3ac..6c2bd7c 100644 --- a/crates/lumenwright-cli/Cargo.toml +++ b/crates/lumenwright-cli/Cargo.toml @@ -6,8 +6,6 @@ version.workspace = true license.workspace = true repository.workspace = true rust-version = "1.90" -# Not published to crates.io: the command is installed from the repository (README, Install). -publish = false readme = "../../README.md" documentation = "https://github.com/stellarfeline/lumenwright#readme" keywords = ["minecraft", "renderer", "path-tracing", "cli", "screenshot"] @@ -32,7 +30,7 @@ fastnbt = "2.6.3" flate2 = "1.1.10" image = { version = "0.25.10", default-features = false, features = ["png"] } log = "0.4.34" -lumenwright = { version = "0.1.0", path = "../lumenwright", default-features = false } +lumenwright = { version = "0.2.0", path = "../lumenwright", default-features = false } rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" diff --git a/crates/lumenwright-cli/src/job.rs b/crates/lumenwright-cli/src/job.rs index fd4c7dc..96c96e2 100644 --- a/crates/lumenwright-cli/src/job.rs +++ b/crates/lumenwright-cli/src/job.rs @@ -6,6 +6,7 @@ use std::time::Instant; use anyhow::{Context, Result, bail}; use lumenwright::glam::DVec3; use lumenwright::render::sky; +use lumenwright::request::{HqSkyModel, SkyLook}; use lumenwright::scene::block_entities::BlockEntityStats; use lumenwright::scene::camera::Camera; use lumenwright::scene::entities::EntityStats; @@ -15,7 +16,9 @@ use lumenwright::{ChunkRect, EntityPolicy, HqOptions, QuickOptions, Request, Sky use crate::jar; use crate::output; use crate::unifont; -use crate::values::{BackendArg, EmitterSamplingArg, ModeArg, TonemapArg, UnknownEntitiesArg}; +use crate::values::{ + BackendArg, EmitterSamplingArg, ModeArg, SkyArg, TonemapArg, UnknownEntitiesArg, +}; #[derive(Clone, Debug, clap::Args)] pub struct RenderArgs { @@ -76,16 +79,24 @@ pub struct RenderArgs { /// Sun azimuth in degrees (0 = north, 90 = east). #[arg(long, default_value_t = 135.0, allow_hyphen_values = true)] pub sun_azimuth: f32, - /// HQ: sun intensity, as Chunky's (the sun's irradiance is intensity^2.2). - #[arg(long, default_value_t = sky::DEFAULT_SUN_INTENSITY)] - pub sun_intensity: f32, - /// HQ: sky radiance scale. - #[arg(long, default_value_t = 1.0)] - pub sky_intensity: f32, - /// HQ: Chunky's emitter intensity (an emitting surface emits its texel colour squared - /// times its block's emittance times this; 0 turns emitters off). - #[arg(long, default_value_t = lumenwright::request::DEFAULT_EMITTER_INTENSITY)] - pub emitter_intensity: f32, + /// HQ: the sky the frame is lit by and drawn under: by the time of day (Chunky's by day, + /// the game's at dusk and night, mixed between by the client's sky light factor), or + /// either one at every hour. + #[arg(long, value_enum, default_value_t = SkyArg::Auto)] + pub sky: SkyArg, + /// HQ, Chunky's sky (`--sky chunky` or `auto`): sun intensity, as Chunky's (the sun's + /// irradiance is intensity^2.2). Default 1.25. + #[arg(long)] + pub sun_intensity: Option, + /// HQ, Chunky's sky (`--sky chunky` or `auto`): the Preetham sky's radiance scale. + /// Default 1. + #[arg(long)] + pub sky_intensity: Option, + /// HQ: the emitter intensity (an emitting surface emits its texel colour squared times + /// its block's emittance times this; 0 turns emitters off). Default: each look's own, + /// mixed as the sky is: Chunky's 13, and the game's look's, authored by Lumenwright. + #[arg(long)] + pub emitter_intensity: Option, /// HQ: how a diffuse surface samples the emitters near it (Chunky's strategies). #[arg(long, value_enum, default_value_t = EmitterSamplingArg::One)] pub emitter_sampling: EmitterSamplingArg, @@ -95,8 +106,10 @@ pub struct RenderArgs { pub prevent_normal_emitter: bool, #[arg(long, default_value_t = 1.0)] pub exposure: f32, - #[arg(long, value_enum, default_value_t = TonemapArg::Gamma)] - pub tonemap: TonemapArg, + /// HQ: the tone curve. Default: each look's own, mixed as the sky is: `gamma` (Chunky's) + /// and `filmic-mid-grey` (mid-grey shown as the game shows it). + #[arg(long, value_enum)] + pub tonemap: Option, /// Quick: vanilla brightness option (0 = moody, 0.5 = default, 1 = bright). #[arg(long, default_value_t = 0.5)] pub brightness: f32, @@ -228,6 +241,31 @@ pub fn run(a: &RenderArgs) -> Result { })?; let opening = t.elapsed().as_secs_f64(); let r = a.render_distance.max(1); + let look = match a.sky { + SkyArg::Auto => SkyLook::ByTime, + SkyArg::Game => SkyLook::Game, + SkyArg::Chunky => SkyLook::Chunky, + }; + if look == SkyLook::Game { + for (flag, given) in [ + ("--sun-intensity", a.sun_intensity.is_some()), + ("--sky-intensity", a.sky_intensity.is_some()), + ] { + if given { + bail!( + "{flag} scales Chunky's sun and sky, which `--sky game` does not draw: \ + the game's sky casts the lightmap's light and its sun casts none. \ + Remove {flag}, or pass `--sky chunky`" + ); + } + } + } + let hq_sky = HqSkyModel { + look, + render_distance: (r * 16) as f32, + sun_intensity: a.sun_intensity.unwrap_or(sky::DEFAULT_SUN_INTENSITY), + sky_intensity: a.sky_intensity.unwrap_or(1.0), + }; let req = Request { world: WorldSource::Opened(&world), chunks: ChunkRect::around(cam.pos, r), @@ -260,15 +298,14 @@ pub fn run(a: &RenderArgs) -> Result { emitter_sampling: a.emitter_sampling.into(), prevent_normal_emitter: a.prevent_normal_emitter, exposure: a.exposure, - tonemap: a.tonemap.into(), + tonemap: a.tonemap.map(Into::into), }, sky: Sky::Vanilla { time: a.time, sun_dir: a .sun_altitude .map(|alt| sky::sun_direction(alt, a.sun_azimuth)), - sun_intensity: a.sun_intensity, - sky_intensity: a.sky_intensity, + hq: hq_sky, }, }; let out = lumenwright::render(&req)?; diff --git a/crates/lumenwright-cli/src/values.rs b/crates/lumenwright-cli/src/values.rs index 115d464..e38367d 100644 --- a/crates/lumenwright-cli/src/values.rs +++ b/crates/lumenwright-cli/src/values.rs @@ -69,9 +69,26 @@ mirror! { Aces, /// Hable / Uncharted 2 filmic, then the display gamma. Filmic, + /// Hable read brighter so linear 0.18 shows as the game shows it (0.18^(1/2.2)), + /// then the display gamma. + FilmicMidGrey, } } +/// `--sky`: the library's [`lumenwright::request::SkyLook`]; the looks' values +/// come from their own flags. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum SkyArg { + /// By the time of day: Chunky's sky and sun on the client's day plateau, the game's sky + /// at night, mixed between them by the client's `visual/sky_light_factor`. + Auto, + /// The game's own sky at the tick, lit as the client lights it: the lightmap's sky + /// light, no sunlight, the client's sky, sun, moon and stars drawn. + Game, + /// Chunky's simulated sky: the Preetham sky and a sun that lights and shadows. + Chunky, +} + mirror! { /// `--emitter-sampling`. EmitterSamplingArg => EmitterSampling { diff --git a/crates/lumenwright-cli/tests/library.rs b/crates/lumenwright-cli/tests/library.rs index b3d955c..aabeefa 100644 --- a/crates/lumenwright-cli/tests/library.rs +++ b/crates/lumenwright-cli/tests/library.rs @@ -12,8 +12,8 @@ use lumenwright::render::{BackendKind, Mode}; use lumenwright::scene::camera::Camera; use lumenwright::world::Dimension; use lumenwright::{ - ChunkRect, EntityPolicy, HqOptions, QuickOptions, Rendered, Request, RequestError, Sky, - WorldSource, + ChunkRect, EntityPolicy, HqOptions, HqSkyModel, QuickOptions, Rendered, Request, RequestError, + Sky, WorldSource, }; use lumenwright_cli::fixture; use lumenwright_cli::jar; @@ -115,8 +115,10 @@ fn request<'a>(world: &'a Path, jar: &'a Path, mode: Mode) -> Request<'a> { sky: Sky::Vanilla { time: None, sun_dir: Some(sky::sun_direction(40.0, 135.0)), - sun_intensity: sky::DEFAULT_SUN_INTENSITY, - sky_intensity: 1.0, + hq: HqSkyModel { + render_distance: (RADIUS * 16) as f32, + ..HqSkyModel::default() + }, }, } } @@ -292,8 +294,10 @@ fn quick_sky_follows_the_vanilla_day() { req.sky = Sky::Vanilla { time: Some(ticks), sun_dir: None, - sun_intensity: 1.5, - sky_intensity: 1.0, + hq: HqSkyModel { + sun_intensity: 1.5, + ..HqSkyModel::CHUNKY + }, }; let out = lumenwright::render(&req).unwrap(); // The median of the centre 5×5 pixels (a star may sit on one). diff --git a/crates/lumenwright-cli/tests/render.rs b/crates/lumenwright-cli/tests/render.rs index ae89ebc..96e94ad 100644 --- a/crates/lumenwright-cli/tests/render.rs +++ b/crates/lumenwright-cli/tests/render.rs @@ -519,9 +519,22 @@ fn gpu_matches_cpu_in_quick_mode() { /// of its disc above it (the sky at full light and the sun's horizon test); and a low sun /// at the centre of a narrow view, exposed so the sun texture drawn in the sky does not /// clip. -const DAY_SUN: &[&str] = &["--sun-altitude", "40"]; -const DUSK_SUN: &[&str] = &["--sun-altitude", "-0.5"]; -const SUN_IN_VIEW: &[&str] = &["--sun-altitude", "8", "--sun-azimuth", "330"]; +/// Chunky's sky with a sun at a stated altitude. +const DAY_SUN: &[&str] = &["--sky", "chunky", "--sun-altitude", "40"]; +const DUSK_SUN: &[&str] = &["--sky", "chunky", "--sun-altitude", "-0.5"]; +const SUN_IN_VIEW: &[&str] = &[ + "--sky", + "chunky", + "--sun-altitude", + "8", + "--sun-azimuth", + "330", +]; +/// The game's sky in the morning and at the setting sun. +const GAME_DAY: &[&str] = &["--sky", "game", "--time", "4000"]; +const GAME_DUSK: &[&str] = &["--sky", "game", "--time", "12800"]; +/// The default sky half way down the evening ramp (day weight 0.5): both looks mixed. +const AUTO_MID: &[&str] = &["--time", "12205"]; /// A camera on the showcase looking at the [`SUN_IN_VIEW`] sun with a 12 degree view. const SUN_VIEW: &[&str] = &[ "--camera", @@ -546,6 +559,9 @@ fn gpu_matches_cpu_in_hq_mode() { ("showcase", showcase(), &[][..], DAY_SUN), ("showcase-dusk", showcase(), &[][..], DUSK_SUN), ("showcase-sun", showcase(), SUN_VIEW, SUN_IN_VIEW), + ("showcase-game-day", showcase(), &[][..], GAME_DAY), + ("showcase-game-dusk", showcase(), &[][..], GAME_DUSK), + ("showcase-auto-mid", showcase(), &[][..], AUTO_MID), ("block-entities", gallery(), &GALLERY_VIEW[..], DAY_SUN), ( "items", @@ -638,6 +654,8 @@ fn gpu_draws_the_sky_as_the_cpu_does() { "18.5,200,40", "--fov", "150", + "--sky", + "chunky", "--sun-altitude", "5", "--sun-azimuth", @@ -662,6 +680,173 @@ fn gpu_draws_the_sky_as_the_cpu_does() { assert!(worst <= 2, "{worst}/255"); } +/// The game's sky is two maps the host draws and every backend reads the same way: a +/// frame that looks at the setting sun, its glow and the dusk sky (the lens's map) agrees +/// between the CPU and the GPU within 2/255 everywhere. +#[test] +fn gpu_draws_the_game_sky_as_the_cpu_does() { + let jar = require_jar!(); + if !gpu_available() { + eprintln!("skipping: no GPU adapter"); + return; + } + let dir = tmp("parity-game-sky"); + let args = |b: &'static str| { + vec![ + "--mode", + "hq", + "--backend", + b, + "--spp", + "4", + "--width", + "160", + "--height", + "90", + "--camera", + "18.5,120,22.5", + "--look-at", + "-80,125,22.5", + "--fov", + "90", + "--sky", + "game", + "--time", + "12800", + ] + }; + let (cpu, gpu) = (dir.join("sky-cpu.png"), dir.join("sky-gpu.png")); + render(showcase(), &jar, &cpu, &args("cpu")).unwrap(); + render(showcase(), &jar, &gpu, &args("gpu")).unwrap(); + let (a, b) = (load(&cpu), load(&gpu)); + let worst = a + .pixels() + .zip(b.pixels()) + .flat_map(|(p, q)| (0..3).map(move |i| (p.0[i] as i32 - q.0[i] as i32).abs())) + .max() + .unwrap(); + let mean_red = a.pixels().map(|p| p.0[0] as f64).sum::() / (a.width() * a.height()) as f64; + eprintln!( + "hq game sky: largest channel difference {worst}/255 between CPU and GPU (mean red {mean_red:.1})" + ); + assert!( + mean_red > 60.0, + "the frame shows the dusk sky: mean red {mean_red:.1}" + ); + assert!(worst <= 2, "{worst}/255"); +} + +/// Chunky's sun and sky scales mean nothing under the game's sky (the default): either +/// flag is refused by name before anything is drawn, and is taken under `--sky chunky`. +#[test] +fn chunky_scales_are_refused_under_the_game_sky() { + let jar = require_jar!(); + let dir = tmp("game-sky-flags"); + for flag in ["--sun-intensity", "--sky-intensity"] { + let small = [ + "--mode", + "hq", + "--backend", + "cpu", + "--spp", + "1", + "--width", + "16", + "--height", + "9", + ]; + let mut args = small.to_vec(); + args.extend([flag, "2", "--sky", "game"]); + let err = render(showcase(), &jar, &dir.join("refused.png"), &args) + .err() + .unwrap_or_else(|| panic!("{flag} was taken under the game's sky")); + let msg = format!("{err:#}"); + assert!(msg.contains(flag) && msg.contains("--sky chunky"), "{msg}"); + let mut taken = small.to_vec(); + taken.extend([flag, "2", "--sky", "chunky"]); + render(showcase(), &jar, &dir.join("taken.png"), &taken).unwrap(); + // The default mixes Chunky's sky in by day, so it takes them. + let mut auto = small.to_vec(); + auto.extend([flag, "2"]); + render(showcase(), &jar, &dir.join("auto.png"), &auto).unwrap(); + } +} + +/// The default sky goes by the time of day: on the client's day plateau it is Chunky's look +/// byte for byte, on the night plateau the game's, and across the evening ramp of +/// `visual/sky_light_factor` a frame one tick later differs by as little as one tick +/// within either look does. +#[test] +fn the_default_sky_follows_the_day_without_a_jump() { + let jar = require_jar!(); + let dir = tmp("sky-by-time"); + let frame = |name: &str, tick: &str, extra: &[&str]| { + let mut args = vec![ + "--mode", + "hq", + "--backend", + "cpu", + "--spp", + "8", + "--width", + "96", + "--height", + "54", + "--time", + tick, + ]; + args.extend_from_slice(extra); + let out = dir.join(format!("{name}.png")); + render(showcase(), &jar, &out, &args).unwrap(); + load(&out) + }; + let mean = |a: &image::RgbaImage, b: &image::RgbaImage| { + a.pixels() + .zip(b.pixels()) + .map(|(p, q)| { + (0..3) + .map(|i| (p.0[i] as f64 - q.0[i] as f64).abs()) + .sum::() + }) + .sum::() + / (a.width() * a.height() * 3) as f64 + }; + // The plateaus are the two looks. + assert_eq!( + frame("day", "6000", &[]).into_raw(), + frame("day-chunky", "6000", &["--sky", "chunky"]).into_raw() + ); + assert_eq!( + frame("night", "18000", &[]).into_raw(), + frame("night-game", "18000", &["--sky", "game"]).into_raw() + ); + // Across the edges of the ramp (11270 and 13140), one tick moves a frame no more than + // one tick does inside a look. + let inside = mean(&frame("a", "11269", &[]), &frame("b", "11270", &[])); + let (e0, e1) = (frame("c", "11270", &[]), frame("d", "11271", &[])); + let (n0, n1) = (frame("e", "13139", &[]), frame("f", "13140", &[])); + let (day_edge, night_edge) = (mean(&e0, &e1), mean(&n0, &n1)); + eprintln!( + "sky by time: one tick moves a frame by {inside:.3}/255 inside Chunky's look, \ + {day_edge:.3} at the day plateau's edge and {night_edge:.3} at the night's" + ); + assert!(day_edge <= 2.0 * inside.max(0.05), "{day_edge} vs {inside}"); + assert!( + night_edge <= 2.0 * inside.max(0.05), + "{night_edge} vs {inside}" + ); + // Half way down the ramp it is neither look. + let mid = frame("mid", "12205", &[]); + assert_ne!( + mid.as_raw(), + frame("mid-c", "12205", &["--sky", "chunky"]).as_raw() + ); + assert_ne!( + mid.as_raw(), + frame("mid-g", "12205", &["--sky", "game"]).as_raw() + ); +} + /// The CUDA kernel is the CPU kernel compiled to PTX: an HQ frame agrees with /// the CPU's within the GPU bounds above. Quick mode on CUDA is the CPU rasterizer. #[cfg(feature = "cuda")] @@ -677,6 +862,9 @@ fn cuda_matches_cpu_in_hq_mode() { ("showcase", showcase(), &[][..], DAY_SUN), ("showcase-dusk", showcase(), &[][..], DUSK_SUN), ("showcase-sun", showcase(), SUN_VIEW, SUN_IN_VIEW), + ("showcase-game-day", showcase(), &[][..], GAME_DAY), + ("showcase-game-dusk", showcase(), &[][..], GAME_DUSK), + ("showcase-auto-mid", showcase(), &[][..], AUTO_MID), ("block-entities", gallery(), &GALLERY_VIEW[..], DAY_SUN), ( "items", diff --git a/crates/lumenwright-cli/tests/text_displays.rs b/crates/lumenwright-cli/tests/text_displays.rs index 13fde92..cde7eb7 100644 --- a/crates/lumenwright-cli/tests/text_displays.rs +++ b/crates/lumenwright-cli/tests/text_displays.rs @@ -14,7 +14,8 @@ use lumenwright::render::{BackendKind, Mode}; use lumenwright::scene::camera::Camera; use lumenwright::world::Dimension; use lumenwright::{ - ChunkRect, EntityPolicy, HqOptions, QuickOptions, Rendered, Request, Sky, WorldSource, + ChunkRect, EntityPolicy, HqOptions, HqSkyModel, QuickOptions, Rendered, Request, Sky, + WorldSource, }; use lumenwright_cli::fixture::{self, TEXT_DISPLAYS, TextDisplayCase}; use lumenwright_cli::jar; @@ -144,8 +145,10 @@ fn request<'a>(world: &'a Path, jar: &'a Path, mode: Mode, cam: Camera) -> Reque sky: Sky::Vanilla { time: None, sun_dir: Some(sky::sun_direction(45.0, 160.0)), - sun_intensity: 1.5, - sky_intensity: 1.0, + hq: HqSkyModel { + sun_intensity: 1.5, + ..HqSkyModel::CHUNKY + }, }, } } diff --git a/crates/lumenwright-kernel/Cargo.toml b/crates/lumenwright-kernel/Cargo.toml index d27b5b0..1b0a449 100644 --- a/crates/lumenwright-kernel/Cargo.toml +++ b/crates/lumenwright-kernel/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true version.workspace = true license.workspace = true repository.workspace = true -rust-version = "1.85" +rust-version = "1.88" readme = "README.md" documentation = "https://docs.rs/lumenwright-kernel" keywords = ["minecraft", "path-tracing", "no-std", "cuda", "ptx"] diff --git a/crates/lumenwright-kernel/src/integrator.rs b/crates/lumenwright-kernel/src/integrator.rs index 2927d39..24aba6b 100644 --- a/crates/lumenwright-kernel/src/integrator.rs +++ b/crates/lumenwright-kernel/src/integrator.rs @@ -187,6 +187,61 @@ pub fn sky_radiance(scene: &SceneView, d: V3) -> V3 { rgb_from_hsl(hsl[0], hsl[1], hsl[2]) * p.sky_sun[3] } +/// A bilinear lookup in the equirectangular map of `w` × `h` linear RGB texels that +/// starts at word `base` of [`SceneView::sky`]: texel `(i, j)` at `(j w + i) × 3`, its +/// centre at azimuth `(i + ½) / w` turns from +x towards +z and at height +/// `(j + ½) / h · π − π/2` (row 0 at the nadir). The azimuth wraps; the height is held +/// at the first and last rows' centres. +#[cfg_attr(target_arch = "nvptx64", inline(always))] +fn map_lookup(scene: &SceneView, base: u32, w: f32, h: f32, d: V3) -> V3 { + let u = atan2(d.z, d.x) / (2.0 * PI); + let u = ((u % 1.0) + 1.0) % 1.0; + // (asin y + π/2) / π = 1 − acos(y) / π. + let v = 1.0 - acos(clamp(d.y, -1.0, 1.0)) / PI; + let x = u * w - 0.5; + let y = clamp(v * h - 0.5, 0.0, h - 1.0); + let fx = crate::math::floor(x); + let fy = crate::math::floor(y); + let (tx, ty) = (x - fx, y - fy); + let i0 = if fx < 0.0 { w - 1.0 } else { fx }; + let i1 = if fx + 1.0 >= w { 0.0 } else { fx + 1.0 }; + let j1 = crate::math::min(fy + 1.0, h - 1.0); + let sky = scene.sky; + let at = |i: f32, j: f32| -> V3 { + let k = (base + (j as u32 * w as u32 + i as u32) * 3) as usize; + v3(sky[k], sky[k + 1], sky[k + 2]) + }; + let top = at(i0, fy) * (1.0 - tx) + at(i1, fy) * tx; + let bottom = at(i0, j1) * (1.0 - tx) + at(i1, j1) * tx; + top * (1.0 - ty) + bottom * ty +} + +/// The sky a path sees along `d` before its first diffuse bounce: the lens's map +/// (`Params::sky_map`), or, with no maps, Chunky's sky ([`sky_radiance`]). Under the +/// maps the lens and the light differ: what is drawn in the sky (the client's sun, moon +/// and stars among it) is seen and casts no light, and the light carries the colour +/// the host derived it to cast (`lumenwright::render::sky::GameSkyMaps`). +#[cfg_attr(target_arch = "nvptx64", inline(always))] +pub fn sky_lens(scene: &SceneView, d: V3) -> V3 { + let m = scene.params.sky_map; + if m[0] < 1.0 { + return sky_radiance(scene, d); + } + map_lookup(scene, 0, m[0], m[1], d) +} + +/// The light the sky casts along `d` on a path that has bounced diffusely: the +/// light's map, which follows the lens's in [`SceneView::sky`], or, with no maps, +/// Chunky's sky ([`sky_radiance`]), which is the same for the lens and the light. +#[cfg_attr(target_arch = "nvptx64", inline(always))] +pub fn sky_light(scene: &SceneView, d: V3) -> V3 { + let m = scene.params.sky_map; + if m[0] < 1.0 { + return sky_radiance(scene, d); + } + map_lookup(scene, (m[0] * m[1]) as u32 * 3, m[2], m[3], d) +} + /// The sun drawn in the sky: Chunky's `Sun.intersect` (GPL-3.0, chunky-dev/chunky /// 156e2bb). The client's sun texture covers a square around the sun whose half-width is /// four times the sun's radius, laid along the axes `su` and `sv` by the angles @@ -472,7 +527,11 @@ pub fn render_sample(scene: &SceneView, px: u32, py: u32, sample: u32) -> V3 { let h = match hit { None => { if !in_water { - radiance = radiance + beta.mul(sky_radiance(scene, d)); + if bounces == 0 { + radiance = radiance + beta.mul(sky_lens(scene, d)); + } else { + radiance = radiance + beta.mul(sky_light(scene, d)); + } // Only purely specular camera paths see the sun drawn (Chunky's // direct and specular sky hits); diffuse paths already account for // the sun through next-event estimation (Chunky's `FAST`). @@ -547,11 +606,13 @@ pub fn render_sample(scene: &SceneView, px: u32, py: u32, sample: u32) -> V3 { // and, Lumenwright's own, when it is not below the horizon, where the ground // beyond the world hides the sun. Some direction in the cone passes both tests // only when the sun's centre is less than its radius below the surface's plane - // and below the horizon, so no sample is drawn otherwise. + // and below the horizon. So no sample is drawn otherwise, nor for a sun that + // casts no light. let origin = pos + n * eps; let sun = V3::from4(p.sun_dir); let sin_r = sqrt(max(1.0 - p.sun_dir[3] * p.sun_dir[3], 0.0)); - if n.dot(sun) > -sin_r && p.sun_dir[1] > -sin_r { + let lit = p.sun_color[0] > 0.0 || p.sun_color[1] > 0.0 || p.sun_color[2] > 0.0; + if lit && n.dot(sun) > -sin_r && p.sun_dir[1] > -sin_r { let l = sample_sun(scene, &mut rng); let cos_l = n.dot(l); if cos_l > 0.0 && l.y >= 0.0 { diff --git a/crates/lumenwright-kernel/src/scene.rs b/crates/lumenwright-kernel/src/scene.rs index bbb8026..dc1c7e7 100644 --- a/crates/lumenwright-kernel/src/scene.rs +++ b/crates/lumenwright-kernel/src/scene.rs @@ -154,7 +154,7 @@ pub const EMITTER_SAMPLING_ALL: u32 = 3; /// offset x, offset y, offset z, 0]` (offsets in cells, `i32` bit cast). pub const EMITTER_GRID_HEADER: u32 = 8; -/// Per-frame parameters (uniform buffer), 25 × vec4. +/// Per-frame parameters (uniform buffer), 26 × vec4. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] pub struct Params { @@ -217,6 +217,11 @@ pub struct Params { /// The sun texture's sprite in the atlas: x, y, width and height in texels; a width /// of 0 when the client jar has none. pub sun_sprite: [f32; 4], + /// The sky as two equirectangular maps in [`SceneView::sky`] (see + /// [`crate::integrator::sky_lens`]): x, y = the width and height of the map the lens + /// sees, z, w = those of the map of the light the sky casts, which follows it. A width + /// of 0 reads the sky cache instead. + pub sky_map: [f32; 4], } /// Borrowed view of all scene buffers. @@ -241,9 +246,12 @@ pub struct SceneView<'a> { /// neighbours. Cells are cubes of the header's size in grid space, from the grid /// origin. pub emitter_grid: &'a [u32], - /// The sky cache (Chunky's `SkyCache`, see [`crate::integrator::sky_radiance`]): - /// `(n + 1)²` HSL triples for the resolution `n` in `misc_f.z`, entry `(i, j)` at - /// `(i (n + 1) + j) × 3`, `i` along the azimuth and `j` along the height. + /// The sky the integrator reads, in one of two layouts. With + /// [`Params::sky_map`]`.x` 0 it is the sky cache (Chunky's `SkyCache`, see + /// [`crate::integrator::sky_radiance`]): `(n + 1)²` HSL triples for the resolution + /// `n` in `misc_f.z`, entry `(i, j)` at `(i (n + 1) + j) × 3`, `i` along the azimuth + /// and `j` along the height. Otherwise it is two equirectangular maps of linear RGB, + /// the lens's then the light's ([`crate::integrator::sky_lens`]). pub sky: &'a [f32], } diff --git a/crates/lumenwright-kernel/src/tests.rs b/crates/lumenwright-kernel/src/tests.rs index d6646bc..f7d890d 100644 --- a/crates/lumenwright-kernel/src/tests.rs +++ b/crates/lumenwright-kernel/src/tests.rs @@ -719,3 +719,113 @@ fn a_later_coplanar_quad_is_met() { } assert_eq!(n, 32); } + +/// A scene whose sky is two maps: the lens's 4 × 2 (texel `(i, j)` holds `(i, j, 1)`) and +/// the light's 2 × 2 (`(10 + i, 10 + j, 2)`). +fn map_scene() -> TestScene { + let mut s = TestScene::new(false); + s.params.sky_map = [4.0, 2.0, 2.0, 2.0]; + let mut sky = Vec::new(); + for j in 0..2 { + for i in 0..4 { + sky.extend([i as f32, j as f32, 1.0]); + } + } + for j in 0..2 { + for i in 0..2 { + sky.extend([10.0 + i as f32, 10.0 + j as f32, 2.0]); + } + } + s.sky = sky; + s +} + +/// A unit direction at `turns` of azimuth from +x towards +z and `el` radians up. +fn sky_dir(turns: f32, el: f32) -> V3 { + let az = turns * std::f32::consts::TAU; + v3(el.cos() * az.cos(), el.sin(), el.cos() * az.sin()) +} + +fn near(a: V3, b: V3, tol: f32) -> bool { + (a.x - b.x).abs() <= tol && (a.y - b.y).abs() <= tol && (a.z - b.z).abs() <= tol +} + +/// Each map is read at its texel centres exactly and bilinearly between them; the +/// azimuth wraps across the seam at +x, the height holds at the outer rows' centres, and +/// the light's map is the one after the lens's in the buffer. +#[test] +fn the_sky_maps_are_read_bilinearly() { + use crate::integrator::{sky_lens, sky_light}; + use core::f32::consts::{FRAC_PI_2, FRAC_PI_4}; + let s = map_scene(); + let v = s.view(); + let mut checked = 0; + for j in 0..2 { + for i in 0..4 { + let d = sky_dir( + (i as f32 + 0.5) / 4.0, + (j as f32 + 0.5) * FRAC_PI_2 - FRAC_PI_2, + ); + let c = sky_lens(&v, d); + assert!( + near(c, v3(i as f32, j as f32, 1.0), 1e-3), + "({i}, {j}): {c:?}" + ); + checked += 1; + } + } + assert_eq!(checked, 8); + // Across the seam, half way between the last column and the first. + let c = sky_lens(&v, sky_dir(0.0, -FRAC_PI_4)); + assert!(near(c, v3(1.5, 0.0, 1.0), 1e-3), "{c:?}"); + // Half way between the rows, and at the zenith and the nadir, held. + let c = sky_lens(&v, sky_dir(0.125, 0.0)); + assert!(near(c, v3(0.0, 0.5, 1.0), 1e-3), "{c:?}"); + assert!(near( + sky_lens(&v, v3(0.0, 1.0, 0.0)), + v3(1.5, 1.0, 1.0), + 1e-3 + )); + assert!(near( + sky_lens(&v, v3(0.0, -1.0, 0.0)), + v3(1.5, 0.0, 1.0), + 1e-3 + )); + // The light's map. + let c = sky_light(&v, sky_dir(0.25, FRAC_PI_4)); + assert!(near(c, v3(10.0, 11.0, 2.0), 1e-3), "{c:?}"); + let c = sky_light(&v, sky_dir(0.5, 0.0)); + assert!(near(c, v3(10.5, 10.5, 2.0), 1e-3), "{c:?}"); +} + +/// Under the maps a camera ray that meets the sky sees the lens's map, and a surface is +/// lit by the light's map alone: a white top face under a uniform light map of 0.3 shows +/// 0.3 in every sample, whatever the lens's map holds, with no sun light. +#[test] +fn the_lens_sees_one_map_and_a_bounce_is_lit_by_the_other() { + let mut s = TestScene::new(false); + s.params.sky_map = [4.0, 2.0, 2.0, 2.0]; + s.sky = [vec![5.0f32; 4 * 2 * 3], vec![0.3f32; 2 * 2 * 3]].concat(); + s.params.frame = [1, 1, 0, 1]; + s.params.cam_pos = [40.25, 20.0, 8.25, 1e-3]; + s.params.cam_right = [1.0, 0.0, 0.0, 0.0]; + s.params.sun_dir = [0.0, 1.0, 0.0, 0.9995]; + // Down, at the uniform brick's top (y = 16). + s.params.cam_fwd = [0.0, -1.0, 0.0, 1.0]; + s.params.cam_up = [0.0, 0.0, -1.0, 0.0]; + for sample in 0..16 { + let c = crate::render_sample(&s.view(), 0, 0, sample); + assert!(near(c, v3(0.3, 0.3, 0.3), 1e-4), "{sample}: {c:?}"); + } + // Up, at the sky. + s.params.cam_fwd = [0.0, 1.0, 0.0, 1.0]; + s.params.cam_up = [0.0, 0.0, 1.0, 0.0]; + let c = crate::render_sample(&s.view(), 0, 0, 0); + assert!(near(c, v3(5.0, 5.0, 5.0), 1e-4), "{c:?}"); + // A sun that casts light lights the face too; the same with sun colour 0 does not. + s.params.cam_fwd = [0.0, -1.0, 0.0, 1.0]; + s.params.cam_up = [0.0, 0.0, -1.0, 0.0]; + s.params.sun_color = [1.0, 1.0, 1.0, 0.03]; + let lit = crate::render_sample(&s.view(), 0, 0, 0); + assert!(lit.x > 1.0, "{lit:?}"); +} diff --git a/crates/lumenwright-kernel/src/trace.rs b/crates/lumenwright-kernel/src/trace.rs index 4847246..1dac35e 100644 --- a/crates/lumenwright-kernel/src/trace.rs +++ b/crates/lumenwright-kernel/src/trace.rs @@ -327,7 +327,7 @@ pub fn hit_point(o: V3, d: V3, h: &Hit) -> V3 { fn _assert_layout() { // Compile-time size checks used by the GPU mirrors. const _: () = assert!(core::mem::size_of::() == 80); - const _: () = assert!(core::mem::size_of::() == 400); + const _: () = assert!(core::mem::size_of::() == 416); const _: () = assert!(core::mem::size_of::() == 16); const _: () = assert!(core::mem::size_of::() == 32); } diff --git a/crates/lumenwright-ptx/Cargo.lock b/crates/lumenwright-ptx/Cargo.lock index 757f9cc..3394b63 100644 --- a/crates/lumenwright-ptx/Cargo.lock +++ b/crates/lumenwright-ptx/Cargo.lock @@ -30,7 +30,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "lumenwright-kernel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bytemuck", "libm", diff --git a/crates/lumenwright/Cargo.toml b/crates/lumenwright/Cargo.toml index e7fde84..52235e5 100644 --- a/crates/lumenwright/Cargo.toml +++ b/crates/lumenwright/Cargo.toml @@ -21,7 +21,7 @@ flate2 = "1.1.10" glam = "0.34.1" image = { version = "0.25.10", default-features = false, features = ["png"] } log = "0.4.34" -lumenwright-kernel = { version = "=0.1.0", path = "../lumenwright-kernel", default-features = false, features = ["std"] } +lumenwright-kernel = { version = "=0.2.0", path = "../lumenwright-kernel", default-features = false, features = ["std"] } lz4_flex = "0.14.0" pollster = { version = "1.0.1", optional = true } rayon = "1.12.0" diff --git a/crates/lumenwright/src/assets/environment.rs b/crates/lumenwright/src/assets/environment.rs index efcb84e..3b93510 100644 --- a/crates/lumenwright/src/assets/environment.rs +++ b/crates/lumenwright/src/assets/environment.rs @@ -54,6 +54,12 @@ pub struct EnvironmentState { pub fog_end: f32, /// `visual/sky_fog_end_distance`, blocks. pub sky_fog_end: f32, + /// `visual/sky_light_factor`, 0..1: how much of the sky's light reaches a cell open + /// to it (the lightmap's `SkyFactor`). + pub sky_light_factor: f32, + /// `visual/sky_light_color`, `0xAARRGGBB`: the colour of that light (the + /// lightmap's `SkyLightColor`). + pub sky_light_color: u32, /// The dimension's skybox. pub skybox: Skybox, } @@ -80,6 +86,9 @@ const ATTRIBUTES: &[(&str, Kind, f64)] = &[ ("visual/fog_start_distance", Kind::Float, 0.0), ("visual/fog_end_distance", Kind::Float, 1024.0), ("visual/sky_fog_end_distance", Kind::Float, 512.0), + ("visual/sky_light_factor", Kind::Float, 1.0), + // `-1`: opaque white, as the unsigned word `0xFFFFFFFF`. + ("visual/sky_light_color", Kind::Rgb, 4_294_967_295.0), ]; /// The moon phases in `MoonPhase` order. @@ -443,10 +452,51 @@ pub fn sample( fog_start: f("visual/fog_start_distance"), fog_end: f("visual/fog_end_distance"), sky_fog_end: f("visual/sky_fog_end_distance"), + sky_light_factor: f("visual/sky_light_factor"), + sky_light_color: values["visual/sky_light_color"].color(), skybox, }) } +/// The night and day values of `visual/sky_light_factor` over a dimension's timelines: +/// its least and greatest value at the biome, sampled at every keyframe tick of every +/// timeline's track of it (the track is piecewise linear, so its extremes are at +/// keyframes). `None` without environment attributes or without such a track. +pub fn sky_light_factor_range( + store: &ResourceStore, + dimension: &str, + biome: &str, +) -> Option<(f32, f32)> { + let dim = read_json(store, &data_path("dimension_type", dimension))?; + let mut timelines = Vec::new(); + resolve_timelines(store, &dim["timelines"], &mut timelines, 0); + let mut ticks = Vec::new(); + for id in &timelines { + let Some(tl) = read_json(store, &data_path("timeline", id)) else { + continue; + }; + for k in tl["tracks"]["minecraft:visual/sky_light_factor"]["keyframes"] + .as_array() + .into_iter() + .flatten() + { + if let Some(t) = k["ticks"].as_i64() { + ticks.push(t); + } + } + } + if ticks.is_empty() { + return None; + } + let values: Vec = ticks + .iter() + .filter_map(|&t| sample(store, dimension, biome, t).map(|e| e.sky_light_factor)) + .collect(); + let lo = values.iter().copied().fold(f32::INFINITY, f32::min); + let hi = values.iter().copied().fold(f32::NEG_INFINITY, f32::max); + (lo <= hi).then_some((lo, hi)) +} + #[cfg(test)] mod tests { use super::*; @@ -466,7 +516,9 @@ mod tests { br##"{"period_ticks":24000,"tracks":{ "minecraft:visual/sky_color":{"keyframes":[{"ticks":133,"value":"#ffffff"},{"ticks":11867,"value":"#ffffff"},{"ticks":13670,"value":"#000000"},{"ticks":22330,"value":"#000000"}],"modifier":"multiply"}, "minecraft:visual/sun_angle":{"ease":{"cubic_bezier":[0.362,0.241,0.638,0.759]},"keyframes":[{"ticks":6000,"value":360.0},{"ticks":6000,"value":0.0}]}, - "minecraft:visual/star_brightness":{"keyframes":[{"ticks":11373,"value":0.0},{"ticks":13228,"value":0.5},{"ticks":22772,"value":0.5},{"ticks":23758,"value":0.0}],"modifier":"maximum"} + "minecraft:visual/star_brightness":{"keyframes":[{"ticks":11373,"value":0.0},{"ticks":13228,"value":0.5},{"ticks":22772,"value":0.5},{"ticks":23758,"value":0.0}],"modifier":"maximum"}, + "minecraft:visual/sky_light_color":{"keyframes":[{"ticks":730,"value":"#ffffff"},{"ticks":11270,"value":"#ffffff"},{"ticks":13140,"value":"#7a7aff"},{"ticks":22860,"value":"#7a7aff"}],"modifier":"multiply"}, + "minecraft:visual/sky_light_factor":{"keyframes":[{"ticks":730,"value":1.0},{"ticks":11270,"value":1.0},{"ticks":13140,"value":0.24},{"ticks":22860,"value":0.24}],"modifier":"multiply"} }}"## .to_vec(), ); @@ -489,6 +541,20 @@ mod tests { night.sun_angle ); assert_eq!(night.star_brightness, 0.5); + // The lightmap's two inputs: the timeline multiplies the registered defaults, + // opaque white and 1. + assert_eq!(noon.sky_light_color, 0xFFFF_FFFF); + assert_eq!(noon.sky_light_factor, 1.0); + assert_eq!(night.sky_light_color, 0xFF7A_7AFF); + assert!((night.sky_light_factor - 0.24).abs() < 1e-6); + // Half way down the dusk ramp: each 8-bit channel lerped as stored. + let dusk = sample(&s, "minecraft:overworld", "minecraft:plains", 12205).unwrap(); + assert_eq!(dusk.sky_light_color, 0xFFBC_BCFF); + assert!((dusk.sky_light_factor - 0.62).abs() < 1e-5); + // Its night and day values over the timeline. + let (lo, hi) = + sky_light_factor_range(&s, "minecraft:overworld", "minecraft:plains").unwrap(); + assert!((lo - 0.24).abs() < 1e-6 && hi == 1.0, "{lo} {hi}"); // Day 3 at noon: the same as day 0. let later = sample( &s, diff --git a/crates/lumenwright/src/lib.rs b/crates/lumenwright/src/lib.rs index 9390c11..d4147fe 100644 --- a/crates/lumenwright/src/lib.rs +++ b/crates/lumenwright/src/lib.rs @@ -111,6 +111,6 @@ pub mod scene; pub mod world; pub use request::{ - ChunkRect, EmitterSampling, EntityPolicy, HqOptions, QuickOptions, QuickSkyColours, Rendered, - Request, RequestError, Sky, StatedSky, UnknownEntities, WorldSource, render, + ChunkRect, EmitterSampling, EntityPolicy, HqOptions, HqSkyModel, QuickOptions, QuickSkyColours, + Rendered, Request, RequestError, Sky, SkyLook, StatedSky, UnknownEntities, WorldSource, render, }; diff --git a/crates/lumenwright/src/render/gpu/pathtrace.wgsl b/crates/lumenwright/src/render/gpu/pathtrace.wgsl index 7cbcd45..706001c 100644 --- a/crates/lumenwright/src/render/gpu/pathtrace.wgsl +++ b/crates/lumenwright/src/render/gpu/pathtrace.wgsl @@ -27,6 +27,7 @@ struct Params { sun_su: vec4, sun_sv: vec4, sun_sprite: vec4, + sky_map: vec4, } // Quads are read as raw words: the w lanes of p0/eu carry bit-cast integers @@ -426,6 +427,50 @@ fn sky_radiance(d: vec3) -> vec3 { return rgb_from_hsl(hsl.x, hsl.y, hsl.z) * P.sky_sun.w; } +// integrator::map_lookup: bilinear in an equirectangular map of linear RGB in `sky`. +fn map_lookup(base: u32, w: f32, h: f32, d: vec3) -> vec3 { + let u0 = atan2_as(d.z, d.x) / (2.0 * PI); + let u = jmod(jmod(u0, 1.0) + 1.0, 1.0); + let v = 1.0 - acos_as(clamp(d.y, -1.0, 1.0)) / PI; + let x = u * w - 0.5; + let y = clamp(v * h - 0.5, 0.0, h - 1.0); + let fx = floor(x); + let fy = floor(y); + let tx = x - fx; + let ty = y - fy; + let i0 = select(fx, w - 1.0, fx < 0.0); + let i1 = select(fx + 1.0, 0.0, fx + 1.0 >= w); + let j1 = min(fy + 1.0, h - 1.0); + let wu = u32(w); + let k00 = base + (u32(fy) * wu + u32(i0)) * 3u; + let k10 = base + (u32(fy) * wu + u32(i1)) * 3u; + let k01 = base + (u32(j1) * wu + u32(i0)) * 3u; + let k11 = base + (u32(j1) * wu + u32(i1)) * 3u; + let c00 = vec3(sky[k00], sky[k00 + 1u], sky[k00 + 2u]); + let c10 = vec3(sky[k10], sky[k10 + 1u], sky[k10 + 2u]); + let c01 = vec3(sky[k01], sky[k01 + 1u], sky[k01 + 2u]); + let c11 = vec3(sky[k11], sky[k11 + 1u], sky[k11 + 2u]); + let top = c00 * (1.0 - tx) + c10 * tx; + let bottom = c01 * (1.0 - tx) + c11 * tx; + return top * (1.0 - ty) + bottom * ty; +} + +// integrator::sky_lens: what a path sees before its first diffuse bounce. +fn sky_lens(d: vec3) -> vec3 { + if (P.sky_map.x < 1.0) { + return sky_radiance(d); + } + return map_lookup(0u, P.sky_map.x, P.sky_map.y, d); +} + +// integrator::sky_light: the light the sky casts on a path that has bounced. +fn sky_light(d: vec3) -> vec3 { + if (P.sky_map.x < 1.0) { + return sky_radiance(d); + } + return map_lookup(u32(P.sky_map.x * P.sky_map.y) * 3u, P.sky_map.z, P.sky_map.w, d); +} + // integrator::sun_texture: Chunky's Sun.intersect. fn sun_texture(d: vec3) -> vec3 { let sprite = P.sun_sprite; @@ -620,7 +665,11 @@ fn render_sample(px: u32, py: u32, sample: u32) -> vec3 { } if (h.quad == NO_QUAD) { if (!in_water) { - radiance += beta * sky_radiance(d); + if (bounces == 0u) { + radiance += beta * sky_lens(d); + } else { + radiance += beta * sky_light(d); + } if (specular && bounces == 0u) { radiance += beta * sun_texture(d); } @@ -681,7 +730,8 @@ fn render_sample(px: u32, py: u32, sample: u32) -> vec3 { let origin = pos + n * eps; let sun = P.sun_dir.xyz; let sin_r = sqrt(max(1.0 - P.sun_dir.w * P.sun_dir.w, 0.0)); - if (dot(n, sun) > -sin_r && P.sun_dir.y > -sin_r) { + let lit = P.sun_color.x > 0.0 || P.sun_color.y > 0.0 || P.sun_color.z > 0.0; + if (lit && dot(n, sun) > -sin_r && P.sun_dir.y > -sin_r) { let l = sample_sun(); let cos_l = dot(n, l); if (cos_l > 0.0 && l.y >= 0.0) { diff --git a/crates/lumenwright/src/render/post.rs b/crates/lumenwright/src/render/post.rs index c250633..b5b1bae 100644 --- a/crates/lumenwright/src/render/post.rs +++ b/crates/lumenwright/src/render/post.rs @@ -10,8 +10,14 @@ pub enum Tonemap { Gamma, /// ACES filmic fit (Narkowicz), Chunky's `ACESFilmicFilter`. Aces, - /// Hable / Uncharted 2 filmic with Chunky's default curve, `HableToneMappingFilter`. + /// Hable / Uncharted 2 filmic with Chunky's default curve, `HableToneMappingFilter` + /// at its `FILMIC_WORLDS` preset ([`HABLE_FILMIC_WORLDS`]). Filmic, + /// The same Hable curve read brighter by the gain that shows linear 0.18 as the + /// game's transfer shows a decoded colour, `0.18^(1/2.2)` ([`game_tone_curve`]): + /// below mid-grey it sits within a few hundredths of the game's transfer, above it it + /// rolls off along Hable's shoulder where the game would clip. + FilmicMidGrey, } fn aces(x: f32) -> f32 { @@ -19,9 +25,120 @@ fn aces(x: f32) -> f32 { ((x * (a * x + b)) / (x * (c * x + d) + e)).clamp(0.0, 1.0) } -fn hable(x: f32) -> f32 { - let (a, b, c, d, e, f) = (0.15, 0.50, 0.10, 0.20, 0.02, 0.30); - ((x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f)) - e / f +/// A Hable curve's seven parameters, as Chunky's `HableToneMappingFilter` reads them +/// (`loadConfiguration`). Per channel Chunky computes `y = 2 · exposure · x`, `h(y) = +/// (y(Ay + CB) + DE) / (y(Ay + B) + DF) − E/F` with each product of two parameters in +/// float and the rest in double, the white scale `1 / h(W)` in float +/// (`recalculateWhiteScale`), and shows `h(y) · whiteScale` through the display gamma +/// (`processPixel`). Ported, with [`HableCurve::brightened`] and +/// [`HableCurve::mid_grey_gain`], from Delvewright's engine (GPL-3.0, `HableCurve` in +/// `crates/delvec/src/compiler/view/scene.rs` at stellarfeline/delvewright 0fd300da7). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HableCurve { + /// `A`, the shoulder strength. + pub a: f32, + /// `B`, the linear strength. + pub b: f32, + /// `C`, the linear angle. + pub c: f32, + /// `D`, the toe strength. + pub d: f32, + /// `E`, the toe numerator. + pub e: f32, + /// `F`, the toe denominator. + pub f: f32, + /// `W`, the linear value the curve shows as white. + pub w: f32, +} + +/// Chunky's default Hable preset, `FILMIC_WORLDS` (`HableToneMappingFilter.applyPreset`, +/// chunky-dev/chunky 156e2bb). +pub const HABLE_FILMIC_WORLDS: HableCurve = HableCurve { + a: 0.15, + b: 0.5, + c: 0.1, + d: 0.2, + e: 0.02, + f: 0.3, + w: 11.2, +}; + +/// The mid-tone the game's tone curve is anchored at: linear 0.18, the reflectance of the +/// photographic grey card exposure is metered against (Kodak Gray Card, R-27). +pub const MID_GREY: f64 = 0.18; + +impl HableCurve { + /// `1 / h(W)`, in float arithmetic as Chunky computes it. + pub fn white_scale(&self) -> f32 { + let HableCurve { + a, + b, + c, + d, + e, + f, + w, + } = *self; + let num = w * (a * w + c * b) + d * e; + let den = w * (a * w + b) + d * f; + 1.0 / (num / den - e / f) + } + + /// `h(y)`, the parameter products in float and the rest in double. + pub fn h(&self, y: f64) -> f64 { + let (a, b) = (f64::from(self.a), f64::from(self.b)); + let (cb, de, df, ef) = ( + f64::from(self.c * self.b), + f64::from(self.d * self.e), + f64::from(self.d * self.f), + f64::from(self.e / self.f), + ); + (y * (a * y + cb) + de) / (y * (a * y + b) + df) - ef + } + + /// The value the curve holds for linear `x` at exposure 1, before the display gamma. + pub fn before_gamma(&self, x: f64) -> f64 { + self.h(2.0 * x) * f64::from(self.white_scale()) + } + + /// The same curve read `k` times brighter, its white held: `h_k(y) = h(k y)` is the + /// curve with `A k²`, `B k` and `C`, `D`, `E`, `F` (multiply out the numerator and the + /// denominator), and `W / k` keeps `h_k(W / k) = h(W)`, so the white scale is the + /// preset's. + pub fn brightened(&self, k: f64) -> HableCurve { + HableCurve { + a: (f64::from(self.a) * k * k) as f32, + b: (f64::from(self.b) * k) as f32, + w: (f64::from(self.w) / k) as f32, + ..*self + } + } + + /// The gain `k` at which [`HableCurve::brightened`] holds linear `m` at `m` before the + /// display gamma, so `m` shows as the game's transfer shows it. With `y = 2km` and + /// `q = m / whiteScale + E/F`, `h(y) = m / whiteScale` is the quadratic + /// `A(1 − q)y² + (CB − qB)y + (DE − qDF) = 0`, whose positive root is the one on the + /// curve's rising branch. + pub fn mid_grey_gain(&self, m: f64) -> f64 { + let (a, b) = (f64::from(self.a), f64::from(self.b)); + let (cb, de, df, ef) = ( + f64::from(self.c * self.b), + f64::from(self.d * self.e), + f64::from(self.d * self.f), + f64::from(self.e / self.f), + ); + let q = m / f64::from(self.white_scale()) + ef; + let (qa, qb, qc) = (a * (1.0 - q), cb - q * b, de - q * df); + let y = (-qb + (qb * qb - 4.0 * qa * qc).sqrt()) / (2.0 * qa); + y / (2.0 * m) + } +} + +/// **The game's tone curve** ([`Tonemap::FilmicMidGrey`]): Chunky's Hable preset +/// brightened by the gain that shows [`MID_GREY`] as the game shows it (1.463761), the +/// curve Delvewright's engine writes for every Chunky scene (`scene::tone_curve`). +pub fn game_tone_curve() -> HableCurve { + HABLE_FILMIC_WORLDS.brightened(HABLE_FILMIC_WORLDS.mid_grey_gain(MID_GREY)) } /// Encodes one linear channel value, clamped to `[0, 1]`, for display: `v^(1/GAMMA)`. @@ -33,7 +150,11 @@ pub fn encode(v: f32) -> f32 { /// Converts linear radiance to packed display RGBA8, rounding as Chunky's /// `ColorUtil.getRGB` does. pub fn tonemap(hdr: &[[f32; 3]], exposure: f32, op: Tonemap) -> Vec { - let white = 1.0 / hable(11.2); + let curve = match op { + Tonemap::FilmicMidGrey => game_tone_curve(), + _ => HABLE_FILMIC_WORLDS, + }; + let white = f64::from(curve.white_scale()); hdr.iter() .map(|p| { let m = |v: f32| { @@ -41,7 +162,9 @@ pub fn tonemap(hdr: &[[f32; 3]], exposure: f32, op: Tonemap) -> Vec { let v = match op { Tonemap::Gamma => v, Tonemap::Aces => aces(v), - Tonemap::Filmic => hable(v * 2.0) * white, + Tonemap::Filmic | Tonemap::FilmicMidGrey => { + (curve.h(2.0 * f64::from(v)) * white) as f32 + } }; (encode(v) * 255.0 + 0.5) as u32 }; @@ -50,6 +173,49 @@ pub fn tonemap(hdr: &[[f32; 3]], exposure: f32, op: Tonemap) -> Vec { .collect() } +/// Two tone curves mixed in display values: each channel shows `weight × a + (1 − weight) +/// × b`, the two curves' display values before the 8-bit rounding. `weight` 1 gives `a`'s +/// bytes and 0 `b`'s. The mix of the two looks' curves by the day weight +/// ([`crate::render::sky::day_weight`]). +pub fn tonemap_mix( + hdr: &[[f32; 3]], + exposure: f32, + a: Tonemap, + b: Tonemap, + weight: f32, +) -> Vec { + if weight >= 1.0 { + return tonemap(hdr, exposure, a); + } + if weight <= 0.0 { + return tonemap(hdr, exposure, b); + } + let shown = |op: Tonemap, v: f32| -> f32 { + let curve = match op { + Tonemap::FilmicMidGrey => game_tone_curve(), + _ => HABLE_FILMIC_WORLDS, + }; + let v = (v * exposure).max(0.0); + let v = match op { + Tonemap::Gamma => v, + Tonemap::Aces => aces(v), + Tonemap::Filmic | Tonemap::FilmicMidGrey => { + (curve.h(2.0 * f64::from(v)) * f64::from(curve.white_scale())) as f32 + } + }; + encode(v) + }; + hdr.iter() + .map(|p| { + let m = |v: f32| { + let d = weight * shown(a, v) + (1.0 - weight) * shown(b, v); + (d * 255.0 + 0.5) as u32 + }; + m(p[0]) | (m(p[1]) << 8) | (m(p[2]) << 16) | 0xFF00_0000 + }) + .collect() +} + /// Box-filters an RGBA8 image down by an integer factor. pub fn downsample(px: &[u32], w: u32, h: u32, k: u32) -> Vec { if k <= 1 { @@ -96,6 +262,60 @@ mod tests { assert_eq!(tonemap(&[[0.18; 3]], 1.0, Tonemap::Gamma)[0] & 0xFF, 117); } + /// The game's curve holds mid-grey at mid-grey before the display gamma, so 0.18 shows + /// at `0.18^(1/2.2)` = 0.458656 as under the gamma filter; it is the preset read + /// 1.463761 times brighter (the engine's `k` at the pin), with the preset's white. + #[test] + fn the_game_tone_curve_shows_mid_grey_as_the_game() { + let k = HABLE_FILMIC_WORLDS.mid_grey_gain(MID_GREY); + assert!((k - 1.463761).abs() < 1e-6, "{k}"); + let g = game_tone_curve(); + assert!((g.before_gamma(MID_GREY) - MID_GREY).abs() < 1e-6); + assert_eq!( + [g.a, g.b, g.c, g.d, g.e, g.f, g.w], + [0.32138956, 0.7318806, 0.1, 0.2, 0.02, 0.3, 7.651521] + ); + // The preset at k times the light, over 1e-4..7e2. + let mut x = 1e-4; + while x < 7e2 { + let (a, b) = ( + g.before_gamma(x), + HABLE_FILMIC_WORLDS.h(2.0 * k * x) * f64::from(HABLE_FILMIC_WORLDS.white_scale()), + ); + assert!((a - b).abs() <= 1e-5 * b.abs().max(1e-6), "{x}: {a} {b}"); + x *= 1.5; + } + let px = tonemap(&[[0.18; 3]], 1.0, Tonemap::FilmicMidGrey)[0] & 0xFF; + assert_eq!(px, tonemap(&[[0.18; 3]], 1.0, Tonemap::Gamma)[0] & 0xFF); + // At the preset 0.18 shows darker, at 0.393 (Chunky's own default curve). + let preset = tonemap(&[[0.18; 3]], 1.0, Tonemap::Filmic)[0] & 0xFF; + assert_eq!(preset, (0.393285f32 * 255.0 + 0.5) as u32); + // White is white under both. + let w = g.w; + assert_eq!( + tonemap(&[[w; 3]], 1.0, Tonemap::FilmicMidGrey)[0], + 0xFFFF_FFFF + ); + } + + /// The mix gives each curve's bytes at its ends and lies between them inside. + #[test] + fn the_tone_mix_is_each_curve_at_its_ends() { + let hdr: Vec<[f32; 3]> = (0..64).map(|i| [i as f32 * 0.05; 3]).collect(); + let (g, h) = (Tonemap::Gamma, Tonemap::FilmicMidGrey); + assert_eq!(tonemap_mix(&hdr, 1.0, g, h, 1.0), tonemap(&hdr, 1.0, g)); + assert_eq!(tonemap_mix(&hdr, 1.0, g, h, 0.0), tonemap(&hdr, 1.0, h)); + let (a, b, m) = ( + tonemap(&hdr, 1.0, g), + tonemap(&hdr, 1.0, h), + tonemap_mix(&hdr, 1.0, g, h, 0.5), + ); + for i in 0..hdr.len() { + let (x, y, z) = (a[i] & 0xFF, b[i] & 0xFF, m[i] & 0xFF); + assert!(z + 1 >= x.min(y) && z <= x.max(y) + 1, "{i}: {x} {y} {z}"); + } + } + #[test] fn downsample_averages() { let px = vec![0xFF00_0000, 0xFFFF_FFFF, 0xFF00_0000, 0xFFFF_FFFF]; diff --git a/crates/lumenwright/src/render/sky.rs b/crates/lumenwright/src/render/sky.rs index 3051246..1b453f3 100644 --- a/crates/lumenwright/src/render/sky.rs +++ b/crates/lumenwright/src/render/sky.rs @@ -741,6 +741,216 @@ impl Preetham { } } +// ---- The game's sky in HQ mode ------------------------------------------------------ +// +// Ported from Delvewright's engine (GPL-3.0, stellarfeline/delvewright at 0fd300da7: +// `crates/delvec/src/compiler/view/scene.rs` — `lightmap_sky`, `sky_cell` — and +// `crates/delvec/src/compiler/view/sky_image.rs` — `sky_map`), which lights a Chunky scene +// as the pinned 1.21.11 client lights the same tick. The engine's method: +// +// 1. The game lights the world from the sky alone. A cell open to the sky is drawn at the +// lightmap's colour for sky light 15 whatever the sun's direction, and nothing casts a +// shadow from the sun. So the sun casts no light, and the light the sky casts is that +// colour decoded to linear: a Lambertian top face under an open sky of that radiance +// returns its albedo times it, so it shows at the colour the game draws it. +// 2. What the lens sees is the client's own sky at the tick (the quick-mode sky, which is +// the client's `SkyRenderer` evaluated per direction). +// 3. The light keeps the client's sky's directions — warm on the sun's side at dusk — +// scaled, plus the least uniform veil, so that its cosine-weighted mean over the upper +// hemisphere is the lightmap's colour in every channel. +// +// Where Lumenwright departs from the engine, the kernel lets it: Chunky shows the lens and +// casts the light from one map (`Sky.getSkyDiffuseColorInner` feeds both, scaled by +// `apparentSkyLight` or `skyLight`), so the engine's lens sees the veil too, which pales +// the noon zenith. Lumenwright's lens sees the client's sky alone, with its sun, moon and +// stars (which the engine's renderer cannot draw), and only the light carries the veil. + +/// The client's `gamma` option at its default, 0.5: the lightmap's `BrightnessFactor` +/// (the 1.21.11 client's `Options` builds `options.gamma` with the default `0.5d`; +/// `LightTexture` reads it). +pub const DEFAULT_BRIGHTNESS: f32 = 0.5; + +/// The size of the map the lens sees, in texels: 2048 × 1024, about 0.18° a texel, so the +/// client's sun texture (32 texels over 33.4°) is drawn at about six map texels a +/// texture texel. Lumenwright's. +pub const LENS_MAP: [u32; 2] = [2048, 1024]; + +/// The size of the map of the light the sky casts: one degree a texel, the engine's +/// (`sky_image::WIDTH`, `HEIGHT`). It holds no drawn bodies, only the sky's slow +/// gradients. +pub const LIGHT_MAP: [u32; 2] = [360, 180]; + +/// The largest factor the light map may scale the client's sky by: Chunky's +/// `Sky.MAX_INTENSITY`, 50, the bound the engine takes (`sky_image::MAX_SKY_LIGHT`). Under +/// a black night sky the light is the veil alone, `L / 50` scaled by 50. +pub const MAX_SKY_LIGHT: f32 = 50.0; + +/// **The colour the pinned client's lightmap gives a cell open to the whole sky** (sky +/// light 15, block light 0), as the display values the game draws it at, from +/// `visual/sky_light_factor`, `visual/sky_light_color` (sRGB 0..1) and the `gamma` +/// option. The 1.21.11 client's `assets/minecraft/shaders/core/lightmap.fsh`, with the +/// uniforms `LightTexture.updateLightTexture` sets in the overworld: `SkyFactor` and +/// `SkyLightColor` the two attributes, `AmbientLightFactor` the dimension's +/// `ambient_light` 0, no darkness, night vision or boss fog. At level 15 `get_brightness` +/// is 1, so the sky term is the factor times the colour; then `mix(·, 0.75, 0.04)`, the +/// clamp, `notGamma` mixed in by the brightness, and a second `mix(·, 0.75, 0.04)`. The +/// lightmap is an RGBA8 texture, so the value is the nearest 8-bit level. +pub fn lightmap_sky(factor: f32, colour: Vec3, brightness: f32) -> Vec3 { + let mix75 = |c: Vec3| c * 0.96 + Vec3::splat(0.75 * 0.04); + let c = mix75(colour * factor).clamp(Vec3::ZERO, Vec3::ONE); + let max = c.max_element(); + let not_gamma = if max > 0.0 { + let inv = 1.0 - max; + c * ((1.0 - inv * inv * inv * inv) / max) + } else { + c + }; + let c = mix75(c + (not_gamma - c) * brightness); + (c * 255.0).round() / 255.0 +} + +/// **How much of a frame is Chunky's look** under [`crate::request::SkyLook::ByTime`]: +/// where the client's `visual/sky_light_factor` at the tick stands between its night and +/// day values over the day timeline, `(factor − night) / (day − night)`, held to 0..1. +/// +/// The client's own timeline marks the day: on the 1.21.11 day timeline +/// `visual/sky_light_factor` and `visual/sky_light_color` hold their day values, and +/// `visual/sunrise_sunset_color` its zero alpha, from tick 730 to tick 11270, and the +/// factor falls linearly to its night value 0.24 at 13140, holds it to 22860, and rises +/// back by 730. So the weight is 1 (Chunky's look) on that plateau, 0 (the game's) on the +/// night plateau, and linear in ticks between them, the factor's own ramp: a frame one tick +/// later moves the weight by at most 1/1870. A timeline with no day cycle (the factor +/// constant) gives 1. The mix is Lumenwright's; its source quantity is the client's. +pub fn day_weight(factor: f32, night: f32, day: f32) -> f32 { + if day - night <= f32::EPSILON { + return 1.0; + } + ((factor - night) / (day - night)).clamp(0.0, 1.0) +} + +/// Mixes Chunky's sky into both of the game's maps by `weight`: each texel becomes +/// `weight × chunky(d) + (1 − weight) × texel` for the texel's direction `d`, where +/// `chunky` is Chunky's sky as the kernel reads it. Light transport is linear in the +/// sky's radiance, so with Chunky's sun lighting at the same weight the frame is the +/// weighted mix of the two looks' radiances. +pub fn blend_chunky_sky(maps: &mut GameSkyMaps, weight: f32, chunky: impl Fn(Vec3) -> Vec3 + Sync) { + use rayon::prelude::*; + let [lw, lh, gw, gh] = maps.sizes.map(|v| v as u32); + let (lens, light) = maps.buffer.split_at_mut((lw * lh * 3) as usize); + for (buf, w, h) in [(lens, lw, lh), (light, gw, gh)] { + buf.par_chunks_mut(3).enumerate().for_each(|(k, t)| { + let c = chunky(map_direction(k as u32 % w, k as u32 / w, w, h)); + for (i, v) in t.iter_mut().enumerate() { + *v = weight * c[i] + (1.0 - weight) * *v; + } + }); + } +} + +/// The direction of texel `(i, j)` of a `w` × `h` equirectangular map, as the kernel's +/// `map_lookup` reads it: its centre at azimuth `(i + ½) / w` turns from +x towards +z +/// and height `(j + ½) / h · π − π/2`, row 0 at the nadir. +pub fn map_direction(i: u32, j: u32, w: u32, h: u32) -> Vec3 { + let az = (i as f32 + 0.5) / w as f32 * std::f32::consts::TAU; + let el = (j as f32 + 0.5) / h as f32 * std::f32::consts::PI - std::f32::consts::FRAC_PI_2; + Vec3::new(el.cos() * az.cos(), el.sin(), el.cos() * az.sin()) +} + +/// The client's sky drawn into a `w` × `h` map, decoded to linear (`c^2.2`, as the kernel +/// decodes a texel). +fn draw_map( + sky: &QuickSky, + w: u32, + h: u32, + atlas: &[u32], + atlas_w: u32, + stars: &[u32], +) -> Vec { + use rayon::prelude::*; + (0..w * h) + .into_par_iter() + .map(|k| { + let d = map_direction(k % w, k / w, w, h); + let c = sky.background(d, atlas, atlas_w, stars); + c.powf(lumenwright_kernel::math::GAMMA) + }) + .collect() +} + +/// The two maps the kernel reads under the game's sky ([`lumenwright_kernel::Params`] +/// `sky_map`), and how the light was made. +#[derive(Clone, Debug)] +pub struct GameSkyMaps { + /// The lens's map ([`LENS_MAP`]), then the light's ([`LIGHT_MAP`]), linear RGB. + pub buffer: Vec, + /// `Params::sky_map`: the two maps' sizes. + pub sizes: [f32; 4], + /// The lightmap's colour decoded: the light an open top face takes, linear. + pub light: Vec3, + /// The cosine-weighted mean of the client's sky over the upper hemisphere, linear. + pub mean: Vec3, + /// The factor the client's sky is scaled by in the light: the largest, up to + /// [`MAX_SKY_LIGHT`], that leaves the veil non-negative in every channel. + pub scale: f32, + /// The uniform radiance added to the client's sky before the scale. + pub veil: Vec3, +} + +/// **The game's sky for one frame.** `lens` is the client's sky as the lens sees it; +/// `light` the same sky with nothing drawn in it (no sun, moon or stars, which cast no +/// light in the game); `sky_light_factor` and `sky_light_color` (sRGB 0..1) the lightmap's +/// two attributes at the tick. +/// +/// The light map is `scale × (G + veil)` for the client's sky `G` drawn in `light`: with +/// `Ē` its cosine-weighted mean over the upper hemisphere and `L` the lightmap's colour +/// decoded, `scale = min(MAX_SKY_LIGHT, min over channels of L / Ē)` and `veil = L / scale +/// − Ē`, so a top face open to the whole map takes exactly `L` (the engine's +/// `sky_image::sky_map`, the least uniform veil that makes the light exact). +pub fn game_sky_maps( + lens: &QuickSky, + light: &QuickSky, + sky_light_factor: f32, + sky_light_color: Vec3, + atlas: &[u32], + atlas_w: u32, + stars: &[u32], +) -> GameSkyMaps { + let [lw, lh] = LENS_MAP; + let [gw, gh] = LIGHT_MAP; + let seen = draw_map(lens, lw, lh, atlas, atlas_w, stars); + let game = draw_map(light, gw, gh, atlas, atlas_w, stars); + let (mut acc, mut weight) = (glam::DVec3::ZERO, 0.0f64); + for (k, c) in game.iter().enumerate() { + let d = map_direction(k as u32 % gw, k as u32 / gw, gw, gh); + if d.y > 0.0 { + let w = (d.y * (1.0 - d.y * d.y).max(0.0).sqrt()) as f64; + acc += c.as_dvec3() * w; + weight += w; + } + } + let mean = (acc / weight).as_vec3(); + let target = lightmap_sky(sky_light_factor, sky_light_color, DEFAULT_BRIGHTNESS) + .powf(lumenwright_kernel::math::GAMMA); + let mut scale = MAX_SKY_LIGHT; + for k in 0..3 { + if mean[k] > 0.0 { + scale = scale.min(target[k] / mean[k]); + } + } + let veil = (target / scale - mean).max(Vec3::ZERO); + let mut buffer = Vec::with_capacity((seen.len() + game.len()) * 3); + buffer.extend(seen.iter().flat_map(|c| c.to_array())); + buffer.extend(game.iter().flat_map(|c| ((*c + veil) * scale).to_array())); + GameSkyMaps { + buffer, + sizes: [lw as f32, lh as f32, gw as f32, gh as f32], + light: target, + mean, + scale, + veil, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1184,6 +1394,187 @@ mod tests { assert!(worst < 1e-4, "{worst:.2e}"); } + /// `lightmap.fsh` of the 1.21.11 client transcribed in `f64`, every uniform at its + /// overworld value, at sky light 15 and block light 0. + fn lightmap_fsh(sky_factor: f64, sky_colour: [f64; 3], brightness: f64) -> [f64; 3] { + let get_brightness = |level: f64| level / (4.0 - 3.0 * level); + let block = get_brightness(0.0) * 1.5; + // `floor(texCoord.y * 16) / 15` at sky light 15. + let level = 15.0f64; + let sky = get_brightness(level / 15.0) * sky_factor; + let mut c = [ + block, + block * ((block * 0.6 + 0.4) * 0.6 + 0.4), + block * (block * block * 0.6 + 0.4), + ]; + // mix(color, AmbientColor, AmbientLightFactor = 0). + for k in 0..3 { + c[k] += sky_colour[k] * sky; + c[k] = c[k] * 0.96 + 0.75 * 0.04; + // DarkenWorldFactor 0, no night vision, DarknessScale 0. + c[k] = c[k].clamp(0.0, 1.0); + } + let max = c[0].max(c[1]).max(c[2]); + let inv = 1.0 - max; + let scaled = 1.0 - inv * inv * inv * inv; + std::array::from_fn(|k| { + let ng = c[k] * (scaled / max); + let v = c[k] + (ng - c[k]) * brightness; + v * 0.96 + 0.75 * 0.04 + }) + } + + /// The lightmap is the client's shader at sky light 15, read at its 8-bit level; at the + /// setting sun (tick 12782: factor 0.385497, colour `#9393ff`) its light, decoded, is + /// the engine's `sky_cell` light 0.374446 (peak channel) to within that level. + #[test] + fn the_sky_light_is_the_clients_lightmap() { + let mut checked = 0; + for f in [0.0f32, 0.24, 0.385497, 0.62, 1.0] { + for col in [ + [1.0f32, 1.0, 1.0], + [0x7a as f32 / 255.0, 0x7a as f32 / 255.0, 1.0], + ] { + for b in [0.0f32, 0.5, 1.0] { + let got = lightmap_sky(f, Vec3::from(col), b); + let want = lightmap_fsh(f as f64, col.map(f64::from), b as f64); + for k in 0..3 { + assert!( + (got[k] as f64 - want[k]).abs() <= 0.5 / 255.0 + 1e-6, + "{f} {col:?} {b}: {got} vs {want:?}" + ); + assert_eq!((got[k] * 255.0).fract(), 0.0, "an 8-bit level"); + } + checked += 1; + } + } + } + assert_eq!(checked, 30); + let c = 0x93 as f32 / 255.0; + let l = lightmap_sky(0.385497, Vec3::new(c, c, 1.0), DEFAULT_BRIGHTNESS) + .powf(lumenwright_kernel::math::GAMMA); + let engine = 0.374446f32; + let level = (engine.powf(1.0 / 2.2) * 255.0).round() / 255.0; + assert!( + (l.z - level.powf(2.2)).abs() < 2e-3, + "{l} against the engine's {engine} at its 8-bit level" + ); + } + + /// A dusk sky as the client draws it (the timeline at tick 12800 over the overworld, + /// the camera facing the sun's side). + fn dusk_description(with_bodies: bool) -> SkyDescription { + SkyDescription { + sky: Vec3::new(0.18, 0.24, 0.37), + fog: Vec3::new(0.62, 0.40, 0.33), + sky_fog_end: 192.0, + render_fog: (172.8, 192.0), + env_fog: (0.0, 1024.0), + sky_disc: true, + sunrise: [0.98, 0.57, 0.25, 0.62], + sun_angle: 90.29, + sun: with_bodies.then_some((celestial_direction(90.29), [0.0, 0.0, 2.0, 2.0])), + moon: None, + star_angle: 90.29, + star_brightness: if with_bodies { 0.38 } else { 0.0 }, + } + } + + fn camera() -> CameraRays { + CameraRays { + forward: Vec3::new(-0.83, -0.35, 0.43).normalize(), + right: Vec3::new(-0.46, 0.0, -0.89).normalize(), + up: Vec3::Y, + tan_half_fov: 0.577, + aspect: 16.0 / 9.0, + } + } + + /// The light the game's sky casts on a top face open to the whole sky is the + /// lightmap's colour in every channel: the cosine-weighted mean of the light's map + /// over the upper hemisphere, measured here by a second method (a midpoint rule on a + /// finer grid of directions through the kernel's own lookup), is the target; the + /// veil is non-negative and zero in the channel that bounds the scale; the sun is in + /// the lens's map and not in the light's. + #[test] + fn the_game_sky_casts_the_lightmaps_light() { + let atlas = vec![0xFFFF_FFFFu32; 4]; + let cam = camera(); + let lens = QuickSky::new(&cam, 16, 9, &dusk_description(true)); + let bare = QuickSky::new(&cam, 16, 9, &dusk_description(false)); + let colour = Vec3::new(0x92 as f32, 0x92 as f32, 0xff as f32) / 255.0; + let maps = game_sky_maps(&lens, &bare, 0.378182, colour, &atlas, 2, star_table()); + let [lw, lh] = LENS_MAP; + let [gw, gh] = LIGHT_MAP; + assert_eq!(maps.buffer.len(), ((lw * lh + gw * gh) * 3) as usize); + assert!(maps.veil.min_element() >= 0.0, "{}", maps.veil); + assert!( + maps.veil.min_element() == 0.0 || maps.scale == MAX_SKY_LIGHT, + "{} {}", + maps.veil, + maps.scale + ); + let params = lumenwright_kernel::Params { + sky_map: maps.sizes, + ..Default::default() + }; + let view = lumenwright_kernel::SceneView { + params: ¶ms, + grid: &[], + models: &[], + quads: &[], + atlas: &[], + emitters: &[], + emitter_grid: &[], + sky: &maps.buffer, + }; + let (mut acc, mut wsum) = (glam::DVec3::ZERO, 0.0f64); + let (na, ne) = (720, 180); + for i in 0..na { + for j in 0..ne { + let az = (i as f64 + 0.5) / na as f64 * std::f64::consts::TAU; + let el = (j as f64 + 0.5) / ne as f64 * std::f64::consts::FRAC_PI_2; + let d = lumenwright_kernel::math::v3( + (el.cos() * az.cos()) as f32, + el.sin() as f32, + (el.cos() * az.sin()) as f32, + ); + let c = lumenwright_kernel::integrator::sky_light(&view, d); + let w = el.sin() * el.cos(); + acc += glam::DVec3::new(c.x as f64, c.y as f64, c.z as f64) * w; + wsum += w; + } + } + let cast = acc / wsum; + eprintln!( + "game sky at dusk: light {} cast {cast} (scale {}, mean {}, veil {})", + maps.light, maps.scale, maps.mean, maps.veil + ); + for k in 0..3 { + let rel = (cast[k] - maps.light[k] as f64).abs() / maps.light[k] as f64; + assert!(rel < 2e-3, "channel {k}: {cast} against {}", maps.light); + } + // The sun: drawn in the lens's map, absent from the light's. + let sun = celestial_direction(90.29); + let d = lumenwright_kernel::math::v3(sun.x, sun.y.max(0.01), sun.z); + let seen = lumenwright_kernel::integrator::sky_lens(&view, d); + let lit = lumenwright_kernel::integrator::sky_light(&view, d) / maps.scale; + assert!(seen.z > 0.9 && seen.z > lit.z + 0.3, "{seen:?} {lit:?}"); + } + + /// The weight is 1 on the day plateau, 0 on the night plateau, linear in the factor + /// between, held at the ends, and 1 for a timeline with no day cycle. + #[test] + fn the_day_weight_follows_the_sky_light_factor() { + assert_eq!(day_weight(1.0, 0.24, 1.0), 1.0); + assert_eq!(day_weight(0.24, 0.24, 1.0), 0.0); + assert!((day_weight(0.62, 0.24, 1.0) - 0.5).abs() < 1e-6); + assert!((day_weight(0.378182, 0.24, 1.0) - 0.181818).abs() < 1e-5); + assert_eq!(day_weight(1.2, 0.24, 1.0), 1.0); + assert_eq!(day_weight(0.1, 0.24, 1.0), 0.0); + assert_eq!(day_weight(0.5, 0.5, 0.5), 1.0); + } + #[test] fn altitude_azimuth() { assert!((sun_direction(90.0, 0.0) - Vec3::Y).length() < 1e-5); diff --git a/crates/lumenwright/src/request.rs b/crates/lumenwright/src/request.rs index dbeb502..ff027ee 100644 --- a/crates/lumenwright/src/request.rs +++ b/crates/lumenwright/src/request.rs @@ -123,9 +123,20 @@ impl Default for QuickOptions { } /// Chunky's default emitter intensity (`Scene.DEFAULT_EMITTER_INTENSITY`, -/// chunky-dev/chunky 156e2bb). +/// chunky-dev/chunky 156e2bb): the emitter intensity of Chunky's look. pub const DEFAULT_EMITTER_INTENSITY: f32 = 13.0; +/// The emitter intensity of the game's look, 137.9628 (Chunky's 13 times 10.6125): +/// **authored by Lumenwright**, neither a vanilla nor a Chunky value. Chunky's emitters +/// light little beyond their block where the game's block light reaches 14; this scale +/// stands in for that, as Delvewright's engine's does. Fitted on the Treehouse Camp's +/// whole-camp view at dusk (tick 12800, day weight 0.181818, four bounces, emitters sampled +/// `ONE`): the default frame's emitter intensity there, `0.181818 × 13 + 0.818182 × this` = +/// 115.2423, is the one at which the mean display luma of its lantern-lit pixels (where the +/// emitters at the engine's intensity outshine the sky; 128 564 of 921 600) equals that of +/// the engine's Chunky render of the frame, 41.993 (`docs/research/game-sky.md`, *Lanterns*). +pub const GAME_EMITTER_INTENSITY: f32 = 137.9628; + /// Chunky's emitter sampling strategies (`EmitterSamplingStrategy`): which emitters a /// diffuse surface samples, among those its emitter-grid cell lists. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -150,8 +161,10 @@ pub struct HqOptions { /// Random seed; the same seed gives the same image. pub seed: u32, /// Chunky's emitter intensity: an emitting surface emits its texel colour squared - /// times its block's emittance times this. 0 turns emitters off. - pub emitter_intensity: f32, + /// times its block's emittance times this. 0 turns emitters off. `None` takes each + /// look's own, mixed by the day weight: Chunky's [`DEFAULT_EMITTER_INTENSITY`] and, + /// under the game's sky, [`GAME_EMITTER_INTENSITY`]. + pub emitter_intensity: Option, /// How emitters are sampled at a diffuse surface (Chunky's emitter sampling /// strategy). pub emitter_sampling: EmitterSampling, @@ -160,8 +173,10 @@ pub struct HqOptions { pub prevent_normal_emitter: bool, /// Linear multiplier applied to the radiance before tonemapping. pub exposure: f32, - /// The tonemapping operator from linear radiance to display values. - pub tonemap: Tonemap, + /// The tonemapping operator from linear radiance to display values. `None` takes each + /// look's own, mixed in display values by the day weight: Chunky's [`Tonemap::Gamma`] + /// and, under the game's sky, [`Tonemap::FilmicMidGrey`]. + pub tonemap: Option, } impl Default for HqOptions { @@ -170,11 +185,11 @@ impl Default for HqOptions { spp: 64, max_bounces: 4, seed: 0, - emitter_intensity: DEFAULT_EMITTER_INTENSITY, + emitter_intensity: None, emitter_sampling: EmitterSampling::One, prevent_normal_emitter: false, exposure: 1.0, - tonemap: Tonemap::Gamma, + tonemap: None, } } } @@ -186,12 +201,11 @@ pub enum Sky { Vanilla { /// Ticks (0 = sunrise, 6000 = noon); `None` is the world's `DayTime`. time: Option, - /// Unit vector towards the sun; `None` follows `time`. + /// Unit vector towards the sun; `None` follows `time`. Under + /// [`SkyLook::Game`] it moves only the sun drawn in the sky. sun_dir: Option, - /// HQ sun intensity, Chunky's: the sun's irradiance is `intensity^2.2`. - sun_intensity: f32, - /// HQ sky radiance scale. - sky_intensity: f32, + /// What HQ mode lights the frame with and draws in its sky. + hq: HqSkyModel, }, /// The caller's sky, used as given; nothing is derived from the world's time. Stated(StatedSky), @@ -202,12 +216,76 @@ impl Default for Sky { Sky::Vanilla { time: None, sun_dir: None, + hq: HqSkyModel::default(), + } + } +} + +/// The client's default render distance, 12 chunks (`options.renderDistance`), in blocks. +pub const DEFAULT_RENDER_DISTANCE: f32 = 192.0; + +/// Which look HQ mode draws under [`Sky::Vanilla`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SkyLook { + /// By the time of day: Chunky's look on the client's day plateau, the game's at night, + /// and between them a mix weighted by where the client's `visual/sky_light_factor` + /// stands between its night and day values ([`sky::day_weight`]). + ByTime, + /// The game's own sky at the tick, lit as the 1.21.11 client lights it + /// ([`sky::game_sky_maps`]): the lens sees the client's sky (its disc, fog, sunrise + /// glow, sun, moon and stars); the sun casts no light; the sky casts the lightmap's + /// colour for a cell open to it. Needs a client jar with environment attributes + /// (1.21.11 or later). + Game, + /// Chunky's simulated sky: the Preetham sky and a sun at the time's angle that lights + /// and shadows, as Chunky lights a scene ([`sky::hq_sky`]). + Chunky, +} + +/// The sky HQ mode traces under [`Sky::Vanilla`]: the look and each look's settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HqSkyModel { + /// Which look, or a mix of the two by the time of day. + pub look: SkyLook, + /// The client's render distance in blocks, which the game's sky fog and its + /// horizon colour depend on. + pub render_distance: f32, + /// Chunky's sun intensity: its irradiance is `intensity^2.2`. + pub sun_intensity: f32, + /// Chunky's Preetham sky radiance scale. + pub sky_intensity: f32, +} + +impl Default for HqSkyModel { + fn default() -> Self { + HqSkyModel { + look: SkyLook::ByTime, + render_distance: DEFAULT_RENDER_DISTANCE, sun_intensity: sky::DEFAULT_SUN_INTENSITY, sky_intensity: 1.0, } } } +impl HqSkyModel { + /// Chunky's look with Chunky's defaults: a sun of intensity 1.25 and the sky at its own + /// scale. + pub const CHUNKY: HqSkyModel = HqSkyModel { + look: SkyLook::Chunky, + render_distance: DEFAULT_RENDER_DISTANCE, + sun_intensity: sky::DEFAULT_SUN_INTENSITY, + sky_intensity: 1.0, + }; + + /// The game's look at the client's default render distance. + pub const GAME: HqSkyModel = HqSkyModel { + look: SkyLook::Game, + render_distance: DEFAULT_RENDER_DISTANCE, + sun_intensity: sky::DEFAULT_SUN_INTENSITY, + sky_intensity: 1.0, + }; +} + /// A caller-stated sky for both modes. #[derive(Clone, Copy, Debug)] pub struct StatedSky { @@ -496,6 +574,8 @@ pub fn render(req: &Request) -> Result { Sky::Stated(_) => world.level.day_time, }; let env = assets::environment::sample(&store, dimension, &biome, day_time); + // The night and day values of `visual/sky_light_factor` over the day timeline. + let day_range = assets::environment::sky_light_factor_range(&store, dimension, &biome); if env.is_none() { log::warn!( "the client jar carries no environment attributes (before 1.21.11): \ @@ -576,40 +656,196 @@ pub fn render(req: &Request) -> Result { Mode::Hq => { let hq = voxel::build_hq(&scene)?; lap("build voxel scene", &mut t); - let hq_sky = match req.sky { - Sky::Stated(s) => s.hq, - Sky::Vanilla { - time, - sun_dir, - sun_intensity, - sky_intensity, - } => sky::hq_sky(vanilla_sun(time, sun_dir), sun_intensity, sky_intensity), + // How much of the frame is Chunky's look: 1 for `Chunky` and a stated sky, 0 for + // `Game`, and by the time of day for `ByTime` (`sky::day_weight`). + let (model, weight) = match req.sky { + Sky::Stated(_) => (None, 1.0), + Sky::Vanilla { hq: m, .. } => { + let w = match m.look { + SkyLook::Chunky => 1.0, + SkyLook::Game => 0.0, + SkyLook::ByTime => match (&env, day_range) { + (Some(e), Some((night, day))) => { + sky::day_weight(e.sky_light_factor, night, day) + } + _ => { + log::warn!( + "no day timeline for `visual/sky_light_factor` in this jar: \ + drawing Chunky's sky" + ); + 1.0 + } + }, + }; + (Some(m), w) + } }; - let d = hq_sky.sun_dir; - log::info!( - "hq sun: direction {:.6} {:.6} {:.6}, altitude {:.6} rad, azimuth from +x \ - towards +z {:.6} rad (Chunky's sun angles)", - d.x, - d.y, - d.z, - d.y.clamp(-1.0, 1.0).asin(), - d.z.atan2(d.x) + if weight < 1.0 && env.is_none() { + anyhow::bail!( + "the game's sky needs the environment attributes of a 1.21.11 or later client \ + jar, and this jar has none; draw Chunky's sky instead (SkyLook::Chunky, \ + `--sky chunky`)" + ); + } + let emitter_intensity = req.hq.emitter_intensity.unwrap_or( + weight * DEFAULT_EMITTER_INTENSITY + (1.0 - weight) * GAME_EMITTER_INTENSITY, ); - let params = hq_params(&req.hq, req.width, req.height, &hq, eye, &rays, &hq_sky); + // Chunky's sun and sky, for the Chunky part of the frame. + let chunky_sky = match (req.sky, model) { + (Sky::Stated(s), _) => s.hq, + (Sky::Vanilla { time, sun_dir, .. }, Some(m)) => { + sky::hq_sky(vanilla_sun(time, sun_dir), m.sun_intensity, m.sky_intensity) + } + (Sky::Vanilla { .. }, None) => unreachable!("a vanilla sky has a model"), + }; + let mut params = hq_params( + &req.hq, + emitter_intensity, + req.width, + req.height, + &hq, + eye, + &rays, + &chunky_sky, + ); + let sky_buffer = if weight >= 1.0 { + let d = chunky_sky.sun_dir; + log::info!( + "hq sky: Chunky's (day weight 1); sun direction {:.6} {:.6} {:.6}, altitude \ + {:.6} rad, azimuth from +x towards +z {:.6} rad (Chunky's sun angles); \ + emitter intensity {emitter_intensity}", + d.x, + d.y, + d.z, + d.y.clamp(-1.0, 1.0).asin(), + d.z.atan2(d.x) + ); + sky::sky_cache(¶ms) + } else { + let e = env.as_ref().expect("checked above"); + let render_distance = model.map_or(DEFAULT_RENDER_DISTANCE, |m| m.render_distance); + let rect = |loc: &str| { + scene.atlas.id(loc).map(|id| { + let s = scene.atlas.sprites[id as usize]; + [s.x as f32, s.y as f32, s.w as f32, s.h as f32] + }) + }; + let sun_rect = crate::scene::SUN_TEXTURES.iter().find_map(|t| rect(t)); + let render_fog = ( + render_distance - (render_distance / 10.0).clamp(4.0, 64.0), + render_distance, + ); + let (_, desc) = quick_sky( + &req.sky, + Some(e), + &biome, + world.level.day_time, + f, + render_distance, + render_fog, + sun_rect, + &rect, + ); + // What is drawn in the sky casts no light in the game. + let bare = sky::SkyDescription { + sun: None, + moon: None, + star_brightness: 0.0, + ..desc + }; + let mut maps = sky::game_sky_maps( + &QuickSky::new(&rays, req.width, req.height, &desc), + &QuickSky::new(&rays, req.width, req.height, &bare), + e.sky_light_factor, + rgb(e.sky_light_color), + &scene.atlas.pixels, + scene.atlas.width, + sky::star_table(), + ); + log::info!( + "hq sky: day weight {weight:.6} (sky light factor {:.6} between the \ + timeline's night {:.6} and day {:.6}); the game's at day time {day_time}: \ + sky light colour #{:06x}, light {:.6} {:.6} {:.6} (the lightmap's, linear) \ + = {:.6} x (the sky's mean {:.6} {:.6} {:.6} + veil {:.6} {:.6} {:.6}); \ + sun {}; emitter intensity {emitter_intensity}", + e.sky_light_factor, + day_range.map_or(f32::NAN, |r| r.0), + day_range.map_or(f32::NAN, |r| r.1), + e.sky_light_color & 0xFF_FFFF, + maps.light.x, + maps.light.y, + maps.light.z, + maps.scale, + maps.mean.x, + maps.mean.y, + maps.mean.z, + maps.veil.x, + maps.veil.y, + maps.veil.z, + match desc.sun { + Some((d, _)) => + format!("drawn at altitude {:.6} rad", d.y.clamp(-1.0, 1.0).asin()), + None => "not drawn".to_string(), + } + ); + if weight > 0.0 { + // Chunky's sky, as the kernel reads it through its cache, weighted into + // both maps; Chunky's sun lights and is drawn at the same weight. + let cache = sky::sky_cache(¶ms); + let view = lumenwright_kernel::SceneView { + params: ¶ms, + grid: &[], + models: &[], + quads: &[], + atlas: &[], + emitters: &[], + emitter_grid: &[], + sky: &cache, + }; + sky::blend_chunky_sky(&mut maps, weight, |d| { + let c = lumenwright_kernel::integrator::sky_radiance( + &view, + lumenwright_kernel::math::v3(d.x, d.y, d.z), + ); + Vec3::new(c.x, c.y, c.z) + }); + for c in &mut params.sun_color[..3] { + *c *= weight; + } + params.sun_su[3] *= weight; + } else { + params.sun_color = [0.0, 0.0, 0.0, params.sun_color[3]]; + // The lens's map draws the client's sun; Chunky's is not drawn. + params.sun_sprite = [0.0; 4]; + } + params.sky_map = maps.sizes; + params.misc_f[2] = 0.0; + maps.buffer + }; let frame = HqFrame { scene: &hq, params, spp: req.hq.spp, - sky: sky::sky_cache(¶ms), + sky: sky_buffer, }; - lap("sky cache", &mut t); + lap("sky", &mut t); let started = Instant::now(); let progress = |done: u32, total: u32| { log::debug!("{done}/{total} ({:.1?})", started.elapsed()); }; let hdr = be.render_hq(&frame, &progress)?; lap("render", &mut t); - let px = post::tonemap(&hdr, req.hq.exposure, req.hq.tonemap); + let px = match req.hq.tonemap { + Some(op) => post::tonemap(&hdr, req.hq.exposure, op), + // Each look's own curve, mixed in display values by the day weight. + None => post::tonemap_mix( + &hdr, + req.hq.exposure, + Tonemap::Gamma, + Tonemap::FilmicMidGrey, + weight, + ), + }; (px, Some(hdr)) } }; @@ -749,9 +985,12 @@ fn quick_sky( } } -/// The path tracer's parameters for a camera and a sky. +/// The path tracer's parameters for a camera and a sky, with the emitter intensity the +/// frame is drawn at. +#[allow(clippy::too_many_arguments)] pub fn hq_params( o: &HqOptions, + emitter_intensity: f32, width: u32, height: u32, hq: &voxel::HqScene, @@ -779,7 +1018,7 @@ pub fn hq_params( perez_e: f4(Vec3::from(sky.perez[4]), 0.0), water_color: f4(Vec3::new(0.42, 0.16, 0.10), 1.333), misc_f: [ - o.emitter_intensity, + emitter_intensity, 1e-3, sky::SKY_CACHE_RESOLUTION as f32, 0.0, @@ -801,7 +1040,7 @@ pub fn hq_params( misc_u: [o.seed, 1, hq.models.len() as u32, hq.emitter_count], see_through: [hq.see_through[0], hq.see_through[1], 0, 0], emitter: [ - if o.emitter_intensity > 0.0 { + if emitter_intensity > 0.0 { match o.emitter_sampling { EmitterSampling::None => EMITTER_SAMPLING_NONE, EmitterSampling::One => EMITTER_SAMPLING_ONE, @@ -818,5 +1057,73 @@ pub fn hq_params( sun_su: f4(su, sky::SUN_TEXTURE_BRIGHTNESS), sun_sv: f4(sv, 4.0 * sun_radius), sun_sprite: hq.sun_sprite.map_or([0.0; 4], |r| r.map(|v| v as f32)), + sky_map: [0.0; 4], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The default emitter intensity at the camp's dusk is the fitted 115.2423, and each + /// plateau takes its look's own. + #[test] + fn the_lantern_default_is_the_fit_at_dusk() { + let at = |w: f32| w * DEFAULT_EMITTER_INTENSITY + (1.0 - w) * GAME_EMITTER_INTENSITY; + assert!((at(0.181818) - 115.2423).abs() < 1e-3, "{}", at(0.181818)); + assert_eq!(at(1.0), 13.0); + assert_eq!(at(0.0), GAME_EMITTER_INTENSITY); + } + + /// `AtmosphericFogEnvironment.getBaseColor` in the 1.21.11 client lerps the clear colour + /// towards the sky colour by `1 − Mth.clampedLerp(min(sky fog end / 16, render + /// distance) / 32, 0.25, 1)^0.25`, and `Mth.clampedLerp(delta, start, end)` is + /// `start` below 0, `end` above 1 and `lerp(delta, start, end)` between (both read off + /// the jar's bytecode): at the client's default 12 chunks the weight is + /// `1 − 0.53125^0.25` = 0.146, not `1 − 0.375^0.25` = 0.218. + #[test] + fn the_clear_colour_leans_to_the_sky_as_the_client_does() { + let env = assets::environment::EnvironmentState { + sky_color: 0xFF78_A7FF, + fog_color: 0xFFC0_D8FF, + sunrise_sunset_color: 0, + sun_angle: 0.0, + moon_angle: 180.0, + star_angle: 0.0, + star_brightness: 0.0, + moon_phase: "full_moon".into(), + fog_start: 0.0, + fog_end: 1024.0, + sky_fog_end: 512.0, + sky_light_factor: 1.0, + sky_light_color: 0xFFFF_FFFF, + skybox: assets::environment::Skybox::Overworld, + }; + let sky = Sky::Vanilla { + time: Some(6000), + sun_dir: None, + hq: HqSkyModel::default(), + }; + let none = |_: &str| None; + for (chunks, w) in [ + (12.0f32, 1.0 - 0.53125f32.powf(0.25)), + (2.0, 1.0 - (0.25f32 + 0.75 * 2.0 / 32.0).powf(0.25)), + (40.0, 0.0), + ] { + let (_, desc) = quick_sky( + &sky, + Some(&env), + "minecraft:plains", + 0, + Vec3::Z, + chunks * 16.0, + (0.0, 1.0), + None, + &none, + ); + let lerp = |a: u32, b: u32| (a as f32 + (w * (b as f32 - a as f32)).floor()) / 255.0; + let want = Vec3::new(lerp(0xC0, 0x78), lerp(0xD8, 0xA7), lerp(0xFF, 0xFF)); + assert_eq!(desc.fog, want, "{chunks} chunks"); + } } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ce37d9a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,116 @@ +# Architecture + +[Lumenwright](../README.md) + +## Architecture + +``` +crates/lumenwright/ the library + world/ Anvil regions, paletted chunk sections, light, biomes, entities, level.dat + assets/ blockstate variants/multipart, model baking, atlas, biome tints, font, lang + scene/ material-resolved quads; quick mesh (culling, smooth light + AO); + HQ brick grid (16³ bricks, u16 model instances, entity overlay cells); entity models + request.rs the entry point: Request -> render() -> Rendered + render/ Backend trait + cpu/ tile-binned software rasterizer + lumenwright-kernel on rayon + gpu/ wgpu: quick.wgsl (raster pipeline), pathtrace.wgsl (compute port of the kernel) + cuda.rs lumenwright-kernel compiled to PTX, launched via cudarc +crates/lumenwright-cli/ the `lumenwright` command, over the library's public API + main.rs subcommands, logging + job.rs render: flags -> Request -> render() -> PNG/PFM + jar.rs client-jar resolution and download + output.rs PNG and PFM writers + values.rs flag values for the library's option enums + fixture.rs generated worlds for demo-world and the tests +crates/lumenwright-kernel/ #![no_std] path tracer shared by CPU and CUDA (same source) +crates/lumenwright-ptx/ CUDA entry point (builds for nvptx64-nvidia-cuda) +``` + +Both modes share one scene build. Quick mode turns visible quads into a triangle mesh, the +way the vanilla client does. HQ mode instances block models inside a sparse two-level voxel +grid: empty bricks are skipped and fully buried bricks are collapsed, so a ray needs one +traversal loop and no BVH. Entities go into sparse overlay cells of the same grid. + +Backend parity is by construction. The CPU and CUDA backends run the same Rust kernel. The +WGSL kernel mirrors it line by line and uses the same RNG, so CPU and Metal draw the same +random numbers for each pixel and sample. Builds are deterministic: identical inputs give +bit-identical images. + +Quick mode samples block textures as the 1.21.11 client does: each sprite of the client's +block atlas (`atlases/blocks.json`) gets a mip chain built like `MipmapGenerator` builds it +(a linear-light 2×2 mean; cutout textures keep their alpha-test coverage; the `.mcmeta` +`mipmap_strategy` and `alpha_cutoff_bias` apply), padded and aligned like the client's +stitcher so no level bleeds into a neighbour, and read through `terrain.fsh`'s +`sampleNearest` with trilinear, 4× anisotropic filtering. Pixel art stays sharp up close and +distant terrain does not shimmer. Entities, items and text are read nearest, as the client +reads its other atlases. The quick sky is the client's `SkyRenderer`: the fogged sky disc, the +sunrise and sunset glow, the sun, this night's moon phase and the 1500 stars, with every +colour and angle read from the jar's environment attributes and day timeline (dimension, +biome at the camera, time of day). The world fog adds the client's environmental fog to the +render-distance fog. HQ mode's default sky is this sky, drawn into a map its path tracer +reads; both modes place the sun at the timeline's `sun_angle`. + +Mob geometry lives in Java code in vanilla, not in assets, so it ships as data in +[`crates/lumenwright/assets/entity_models.json`](../crates/lumenwright/assets/entity_models.json): +57 mobs in vanilla's own part, cube and box-UV terms, transcribed from 1.21.11. Adding a mob +needs no Rust changes. Block displays, paintings and mannequin or player skins are built in +code. Anything else is drawn as a box the size of its hitbox. + +Text displays are drawn in the jar's default font (`assets/minecraft/font/default.json` and +the bitmap providers it names), placed as the client places them: the `text` component with +its colours, bold, italic and `translate` keys, split into lines no wider than `line_width`, +`alignment`, the `background` colour or `default_background`, `shadow`, `billboard` and +`transformation`. A `see_through` display is drawn over whatever stands in front of it. A text +display is seen from the front only, as in the game. `text_opacity` and `brightness` are not +read. A character no bitmap provider covers is drawn from the Unifont (see +[Textures](textures.md#textures-the-chunky-approach)), cut and spaced as the client's unihex provider cuts +and spaces it; without a Unifont directory it is left out. + +![entities](renders/entity-lineup.png) + +Block entities that vanilla draws in Java code use the same format, in +[`crates/lumenwright/assets/block_entity_models.json`](../crates/lumenwright/assets/block_entity_models.json), +each model naming the 1.21.11 client class it is transcribed from. Their data comes from the +chunk's `block_entities`: + +* signs and hanging signs, with front and back text in the vanilla bitmap font + (`assets/minecraft/font`), dye colour, glowing text and its outline. A `translate` line + resolves through the `en_us` language files of the jar and the resource packs; +* bells, the lectern's book and the enchanting table's book; +* chests: single, double, trapped, ender and copper; +* banners, with their pattern layers composited in their dye colours; +* decorated pots with their sherds; +* skulls and heads. A player head's skin is the profile's `texture` asset when a resource + pack provides it, otherwise the default skin for the profile's UUID. Skins are never + downloaded. + +![block entities](renders/block-entities-quick-metal.png) +![signs](renders/block-entities-signs.png) + +Items are drawn through the jar's item model definitions (`assets/minecraft/items/*.json`, +the format of 1.21.4 and later), resolved against the stack's components and where it is +drawn. A model with elements is baked as a block model; a flat item is extruded from its +sprite, a front and a back face and one face for every open texel edge, as the client does. +The model's display transform for its context poses it: + +* dropped items (`ground`), bobbing and turned by a phase taken from the entity's UUID, as + a cluster of up to five copies for a large stack; +* item frame and glow item frame contents (`fixed`), turned by `ItemRotation`; +* item displays, in their `item_display` context, `transformation` and `billboard`; +* items in the hands (`thirdperson_righthand`, `thirdperson_lefthand`) and non-armour items + on the head (`head`) of armour stands, mannequins, players and humanoid mobs. Armour + stands take their `Pose` and `ShowArms`. + +Worn armour is drawn from the jar's equipment assets (`assets/minecraft/equipment/*.json`) +on the humanoid armour meshes, dyed leather included. The shield is drawn from data in +[`crates/lumenwright/assets/item_models.json`](../crates/lumenwright/assets/item_models.json), +with the armour meshes, each citing its 1.21.11 client class. `EntityStats` counts the +stacks drawn and not drawn by item id (`items_rendered`, `items_skipped`). + +![items](renders/items-quick.png) + +```sh +target/release/lumenwright demo-world --items /tmp/items +target/release/lumenwright render -w /tmp/items \ + --camera 7.5,67.6,9.5 --look-at 7.5,66.0,2.5 --fov 60 +``` diff --git a/docs/backends.md b/docs/backends.md new file mode 100644 index 0000000..619bdb3 --- /dev/null +++ b/docs/backends.md @@ -0,0 +1,36 @@ +# Backends + +[Lumenwright](../README.md) + +## Backends + +| backend | quick | hq | tested here (Apple M2 Pro) | +|---|---|---|---| +| cpu | software rasterizer | Rust kernel on rayon | yes | +| metal | wgpu render pipeline | WGSL compute | yes, matches the CPU (see below) | +| vulkan / dx12 | same wgpu code | same WGSL | no, but these are the same code paths as Metal | +| cuda | falls back to the CPU rasterizer | Rust kernel as PTX | not on the M2 Pro; on an RTX 4070 Ti SUPER (WSL2) HQ matches the CPU | + +The CUDA backend needs the PTX built first: + +```sh +scripts/build-ptx.sh # rustup target nvptx64-nvidia-cuda + llvm-bitcode-linker +cargo build --release -p lumenwright-cli --features cuda +lumenwright render ... --mode hq --backend cuda +``` + +`libcuda` is loaded at runtime, so CUDA builds work on machines without the CUDA toolkit. + +`scripts/build-ptx.sh` refuses a kernel whose PTX carries an f64 instruction (consumer NVIDIA +GPUs run f64 at a small fraction of f32 speed) or calls a function other than a panic. The +path tracer is inlined whole into the kernel because, with its functions called, the driver's +optimizing PTX compiler (driver 616.92) produced wrong pixels or launches that never finished, +while the same PTX compiled without optimization matched the CPU. + +On WSL2 the driver library is `/usr/lib/wsl/lib/libcuda.so.1`. A Linux NVIDIA driver package +installed inside WSL (Ubuntu's `libnvidia-compute-*`, pulled in by `nvidia-utils-*`) puts a +native `libcuda.so` in `/usr/lib/x86_64-linux-gnu`, which the backend loads first and which +finds no device (`cuInit` returns 100, `CUDA_ERROR_NO_DEVICE`). Run with +`LD_LIBRARY_PATH=/usr/lib/wsl/lib`, or remove that package. + +An HQ frame logs its kernel time by CUDA events beside the wall clock (`--log info`). diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 0000000..ad65976 --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,168 @@ +# Benchmark + +[Lumenwright](../README.md) + +## Design + +The design was written before any run. Every number in the tables comes from this design; +a cell that cannot be measured is marked n/a with its reason. + +### Instruments + +- Lumenwright: the `lumenwright` command built from `f291590` (the code head of + `feat/lw-game-sky`; later commits on the branch are documentation), `--release`; on the PC + with `--features cuda` and the PTX from `scripts/build-ptx.sh` at the same revision. +- Chunky: `chunky-core-2.5.0-SNAPSHOT.474.g156e2bb.jar` (sha256 + `79ea46233cfa4929e738c6074ff8aabf4f8a2e59cfe9925abc3888a782bad15c`) and its libraries, + run headless (`-render -f`), the client jar 1.21.11 (sha1 + `ba2df812c2d12e0219c489c4cd9a5e1f0760f5bd`) as its resources. JDK 17 on the Mac, JDK 21 on + the PC (what each machine has). +- World: the Treehouse Camp save the README's renders use (region files' sha256 list hashes + to `526fa9df…`, the same on both machines). +- Machines: Mac = Apple M2 Pro (10 CPU cores, 16 GPU cores), macOS; PC = AMD Ryzen 7 7800X3D + (16 threads) and RTX 4070 Ti SUPER, Linux under WSL2. +- Timers, both recorded for every run: the renderer's own (Chunky's `renderTime`; + Lumenwright's `render` stage) and the whole command's wall clock (Python `time.time()` on + the Mac, `/proc/uptime` on the PC), which includes reading the world and, for Chunky, + starting the JVM and building its octree. +- Runs: three per cell, the median reported with the three values. In question 1 the + renderers alternate run by run (Chunky, then each Lumenwright backend, three rounds), so a + load change falls on all of them alike. +- Load: the one-minute load average and the three busiest processes are recorded before every + run. Other workers share the Mac; a run is not discarded for load, it is reported with it. + +### The frames + +- **F-chunky**: the whole-camp view, camera 140, 118, 14 looking at 56, 82, 58, vertical + field of view 60°, 1280 × 720, 625 chunks (`--render-distance 12`), four bounces (Chunky + `rayDepth` 5), Chunky's Preetham sky and a sun at altitude 45° from the south-east (Chunky + altitude π/4, azimuth π/4), emitters at intensity 13 sampled `ONE`, gamma tone map, + 250 samples per pixel. Lumenwright: `--sky chunky --tonemap gamma --time 4000 + --sun-altitude 45 --sun-azimuth 135 --spp 250 --max-bounces 4`. Chunky: + `docs/bench/treehouse-camp-bench.json`. It is the one frame both renderers draw by the same + algorithm (Chunky cannot draw the game's sky with two maps). +- **F-game**: the same camera, resolution, chunks and bounces under Lumenwright's defaults + at `f291590` (the game's sky, the mid-grey tone curve, emitters 13 `ONE`), at tick 4000 + (day) and 12800 (dusk). From 0.2.0 that frame is `--sky game` (the default goes by the time + of day), and the game look's emitter intensity is 137.9628; an emitter's intensity scales + the light it adds and not the work, so the times stand. +- **F-quick**: the same camera, resolution and chunks in quick mode at tick 4000. + +### Questions + +1. **Lumenwright against Chunky on the same machine.** Changes: the renderer (Chunky on the + CPU; Lumenwright on each backend the machine has). Fixed: F-chunky (frame, camera, + resolution, 250 spp, four bounces, sky mode, world), the machine; load recorded. + Mac: Chunky CPU, Lumenwright Metal, Lumenwright CPU. PC: Chunky CPU, Lumenwright CUDA, + Lumenwright CPU, Lumenwright Vulkan if WSL2 offers a hardware Vulkan adapter. +2. **Lumenwright's backends against each other.** Changes: the backend. Fixed: the frame + (F-chunky for HQ, the runs of question 1; F-quick for quick mode), the machine; load + recorded. HQ: as question 1. Quick: Mac CPU and Metal; PC CPU (the CUDA backend has no + rasterizer and uses the CPU's). +3. **How cost scales with samples.** Changes: spp, at 64, 256 and 1024. Fixed: F-game + (camera, resolution, bounces, sky mode), the backend (Metal on the Mac, CUDA on the PC), + the machine; day and dusk each run at every spp, so they compare at the same spp; load + recorded. The renders' quality budgets (4096 and 16384 spp in the README) are not speed + cells. + +## Measured on an M2 Pro (10 CPU cores, 16 GPU cores) + +Other work shared the Mac during every session: the one-minute load average before the runs +was 16 to 251 (Spotlight's indexer and another job's compiler and engine), on a 10-core +machine. The Metal runs barely move with it (32.0 to 33.8 s over six runs); the CPU runs of +Chunky and of Lumenwright do (131.6 to 289.7 s), so the Mac's CPU rows are not a measure of +either renderer's CPU speed: the PC's, under a load of 8 to 16 on 16 threads, are. + +### Question 1 and 2 (HQ): F-chunky + +Session 1 (15:11 to 15:46): + +| machine | renderer | own timer, median (runs) s | whole command, median (runs) s | 1-min load before each run | +|---|---|---|---|---| +| Mac | Chunky, CPU | 267.56 (289.74, 209.34, 267.56) | 275.55 (301.44, 213.62, 275.55) | 21.73, 123.60, 142.03 | +| Mac | Lumenwright, Metal | 33.54 (33.54, 33.81, 32.03) | 33.98 (33.98, 34.86, 32.52) | 156.00, 99.89, 251.06 | +| Mac | Lumenwright, CPU | 211.37 (211.37, 227.01, 146.87) | 211.83 (211.83, 227.35, 147.24) | 149.96, 96.09, 233.06 | +| PC | Chunky, CPU | 78.60 (93.18, 77.60, 78.60) | 82.03 (97.69, 81.50, 82.03) | 8.01, 14.40, 14.38 | +| PC | Lumenwright, CUDA | 6.14 (6.15, 6.05, 6.14) | 6.74 (6.74, 6.64, 6.74) | 13.98, 15.35, 15.66 | +| PC | Lumenwright, CPU | 54.84 (54.67, 54.84, 55.05) | 55.14 (54.97, 55.14, 55.34) | 11.98, 13.14, 13.40 | + +Session 2 (15:49 to 16:12), the same design run again: + +| machine | renderer | own timer, median (runs) s | whole command, median (runs) s | 1-min load before each run | +|---|---|---|---|---| +| Mac | Chunky, CPU | 208.31 (179.02, 208.31, 277.95) | 212.33 (183.00, 212.33, 285.99) | 16.37, 74.67, 96.94 | +| Mac | Lumenwright, Metal | 32.61 (32.34, 32.61, 32.93) | 33.08 (32.70, 33.08, 33.47) | 86.59, 84.84, 77.58 | +| Mac | Lumenwright, CPU | 212.86 (131.59, 229.03, 212.86) | 213.39 (132.02, 229.58, 213.39) | 73.67, 75.15, 67.34 | + +### Question 2 (quick): F-quick + +| machine | backend | own timer, median (runs) s | whole command, median (runs) s | 1-min load | +|---|---|---|---|---| +| Mac | Metal | 0.05 (0.06, 0.04, 0.05) | 0.33 (0.36, 0.33, 0.33) | 204.90, 204.90, 204.90 | +| Mac | CPU | 0.11 (0.12, 0.11, 0.09) | 0.39 (0.39, 0.40, 0.37) | 204.90, 204.90, 204.90 | +| PC | CPU | 0.14 (0.14, 0.14, 0.13) | 0.48 (0.47, 0.48, 0.48) | 13.84, 13.84, 13.84 | + +The quick mode's own timer is the `render` stage (rasterizing); the whole command adds +reading the world and the jar and building the mesh. The CUDA backend has no rasterizer (n/a). + +### Question 3: F-game against samples + +| spp | Mac Metal, day | Mac Metal, dusk | PC CUDA, day | PC CUDA, dusk | +|---|---|---|---|---| +| 64 | 5.21 (5.53) | 5.21 (5.54) | 1.07 (1.67) | 1.07 (1.69) | +| 256 | 21.29 (22.24) | 21.50 (21.89) | 4.12 (4.73) | 4.12 (4.74) | +| 1024 | 83.11 (83.45) | 83.50 (83.86) | 16.31 (16.91) | 16.48 (17.10) | + +## Measured on an RTX 4070 Ti SUPER (driver 616.92, WSL2) + +The PC rows of the tables above: AMD Ryzen 7 7800X3D (16 threads), RTX 4070 Ti SUPER, driver +616.92, Linux 6.6.87.2-microsoft-standard-WSL2, JDK 21 for Chunky. Lumenwright on Vulkan: n/a +(WSL2 offers only a software Vulkan adapter, which `lumenwright backends` refuses). Metal and +DX12: n/a on this machine. + +### Answers + +* (1) On the PC, under the same frame, Lumenwright's CUDA backend renders F-chunky 12.8 times + as fast as Chunky on the CPU by the renderers' own timers (78.60 / 6.14 s) and 12.2 times by + the whole command (82.03 / 6.74 s); Lumenwright's CPU backend 1.43 and 1.49 times. On the Mac, + Lumenwright on Metal is 6.4 to 8.0 times as fast as Chunky by its own timer in the two loaded + sessions (208.31 / 32.61, 267.56 / 33.54 s); the CPU comparison there is not taken (above). +* (2) HQ on the PC: CUDA 6.14 s, CPU 54.84 s. On the Mac: Metal 32.6 to 33.5 s. Quick mode: + Metal 0.05 s and CPU 0.11 s on the Mac, CPU 0.14 s on the PC, each about 0.3 to 0.5 s end to + end. +* (3) Cost is linear in samples past a fixed overhead, and day and dusk cost the same under + the game's sky (the sun casts no light, so no shadow rays are traced at any hour): on Metal + 5.2, 21.3 and 83.1 s at 64, 256 and 1024 spp (about 81 ms a sample), on CUDA 1.07, 4.12 and + 16.3 s (about 16 ms a sample). + +The frames: each Lumenwright F-chunky frame on Metal is byte-identical to the frame the +previous benchmark measured (Lumenwright at `0b39f8f`), so `--sky chunky` draws what Chunky's +sky drew before the game's sky existed; against Chunky's frame converged at 4000 samples it is +display mean 1.66/255, SSIM 0.9594, and a Chunky 250-sample frame of this session 1.99/255, +SSIM 0.9575. + +## HQ mode and Chunky on one frame + +F-chunky: the whole-camp view of The Treehouse Camp at 1280×720, 250 samples per pixel, four bounces, with Chunky's default sky, +sun, emitters and tone map, which Lumenwright draws under `--sky chunky` (the full method: [research/chunky-parity.md](research/chunky-parity.md)). +Against Chunky's frame converged at 4000 samples, Lumenwright's 250-sample frame differs by a +mean of 1.66/255 (SSIM 0.959), Chunky's own 250-sample frame by 1.99/255 (SSIM 0.958). At +dusk (`--time 12800`, the sun just below the horizon) the same view differs by 1.21/255 +(SSIM 0.955), Chunky's own 250-sample frame by 1.28/255 (SSIM 0.954); those dusk figures were +measured at `0b39f8f`, whose Chunky sky `--sky chunky` draws unchanged. + +```sh +# Lumenwright (--backend metal on the M2 Pro, cuda on the 4070) +target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ + --sky chunky --tonemap gamma --time 4000 --sun-altitude 45 --sun-azimuth 135 --mode hq --spp 250 --max-bounces 4 --backend metal -o lw.png +# Chunky: the pinned core on its own classpath, as Delvewright's validation/chunky.sh runs it, without its denoise step +mkdir -p scenes && cp docs/bench/treehouse-camp-bench.json scenes/ +java -Djava.awt.headless=true -Dchunky.home=$HOME/.chunky -cp "" se.llbit.chunky.main.Chunky \ + -scene-dir scenes -render treehouse-camp-bench -f +``` + +The Chunky scene ([`docs/bench/treehouse-camp-bench.json`](bench/treehouse-camp-bench.json); its world path is read from the working directory) states the same 625 chunks, the camera +at 140, 118, 14 with Chunky's yaw 0.483 rad and pitch −1.208 rad (looking at 56, 82, 58), a 60° +vertical field of view, `sppTarget` 250, `rayDepth` 5 (four bounces after the first hit, +read off the pinned core's `PathTracer`), `emittersEnabled`, `emitterSamplingStrategy` `ONE`, +and the sun at altitude π/4 and Chunky azimuth π/4 (towards +X +Z). diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..3755a83 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,35 @@ +# The command line + +[Lumenwright](../README.md) + +## Command line (render) + +| flag | default | meaning | +|---|---|---| +| `-w, --world` | required | save directory (contains `level.dat`) | +| `-o, --output` | render.png | the PNG to write | +| `--mode quick\|hq` | quick | vanilla raster or path tracing | +| `--backend auto\|cpu\|gpu\|metal\|vulkan\|dx12\|cuda` | auto | `auto` uses the GPU if one is available, otherwise the CPU | +| `--camera x,y,z` | player eye, else spawn | eye position | +| `--yaw`, `--pitch` | player's | Minecraft convention: yaw 0 = south, 90 = west; pitch > 0 looks down | +| `--look-at x,y,z` | | overrides yaw and pitch | +| `--fov` | 70 | vertical field of view in degrees | +| `--width`, `--height` | 1280×720 | output size | +| `--render-distance` | 8 | chunk radius loaded around the camera | +| `--dimension` | overworld | `overworld`, `nether`, `end` | +| `--time` | world time | ticks: 0 = sunrise, 6000 = noon | +| `--sun-altitude`, `--sun-azimuth` | from time, 135 | degrees (azimuth 0 = north, 90 = east); an altitude overrides `--time` for the sun | +| `--spp`, `--max-bounces`, `--seed` | 64, 4, 0 | HQ sampling; the same seed gives the same image | +| `--sky auto\|game\|chunky` | auto | HQ: by the time of day (Chunky's sky and sun on the client's day plateau, the game's sky on the night plateau, mixed between by the client's `visual/sky_light_factor`), or either look at every hour | +| `--exposure`, `--tonemap gamma\|aces\|filmic\|filmic-mid-grey` | 1.0, each look's | HQ post-processing, Chunky's filters (each ends in the display gamma 2.2); `filmic-mid-grey` is Hable read brighter so linear 0.18 shows as the game shows it. Unstated, each look takes its own (`gamma` for Chunky's, `filmic-mid-grey` for the game's), mixed in display values under `auto` | +| `--sun-intensity`, `--sky-intensity` | 1.25, 1 | HQ, Chunky's sky (`chunky`, `auto`): Chunky's sun intensity (irradiance intensity^2.2) and sky light; refused under `--sky game` | +| `--emitter-intensity` | each look's | HQ: the emitter intensity; an emitting surface emits its texel colour squared times its block's emittance times this (0 turns emitters off). Unstated: 13 (Chunky's) for Chunky's look and 137.9628 for the game's, authored by Lumenwright (fitted to Delvewright's engine's render at dusk), mixed linearly under `auto` | +| `--emitter-sampling none\|one\|one-block\|all`, `--prevent-normal-emitter` | one | HQ: Chunky's emitter sampling strategies, and its "prevent normal emitter with sampling" | +| `--ssaa N` | 1 | quick-mode supersampling | +| `--mipmap-levels N` | 4 | quick-mode mip levels for block textures, 0 (off) to 4, as the client's option | +| `--brightness`, `--flat-lighting` | 0.5 | vanilla brightness slider, and smooth lighting off | +| `--hdr-output out.pfm` | | also write the linear HDR image (HQ) | +| `--no-entities`, `--players`, `--unknown-entities box\|skip` | | entity options | +| `--no-block-entities` | | leave out block entities drawn by Java code | +| `--minecraft-jar`, `--mc-version`, `--unifont`, `--offline`, `--resource-pack` | | the client jar, the Unifont and packs (see [Textures](textures.md#textures-the-chunky-approach)) | +| `--threads`, `--log` | all cores, info | CPU worker threads and log level, for every subcommand | diff --git a/docs/library.md b/docs/library.md new file mode 100644 index 0000000..ebf075e --- /dev/null +++ b/docs/library.md @@ -0,0 +1,69 @@ +# The library + +[Lumenwright](../README.md) + +## Using the crate + + +The library is on crates.io as `lumenwright`, with its kernel `lumenwright-kernel`: + +```toml +[dependencies] +lumenwright = "0.2.0" # CPU, Metal, Vulkan, DX12 +# lumenwright = { version = "0.2.0", default-features = false } # CPU only +# lumenwright = { version = "0.2.0", default-features = false, features = ["gpu"] } +``` + +The `cuda` feature embeds the kernel's PTX, which `scripts/build-ptx.sh` builds in a clone of +this repository (nightly Rust and the `nvptx64-nvidia-cuda` target). In a clone it is found in +`target/ptx/`; from crates.io, set `LUMENWRIGHT_PTX` to the file the script wrote. Without it +the build stops and says so; nothing is downloaded. See [Library](#library) for the API. + +## Library + +`lumenwright::render(&Request)` draws one frame and returns a `Rendered`. The request names +every input: the world (a save directory, or a `World` you opened) and the inclusive chunk +rectangle to load, the client jar and resource packs as paths, the camera, the mode, the +backend, the size, the quick and HQ options, which entities to draw, and the sky. The sky is +either vanilla's at a time of day (`Sky::Vanilla`) or the colours and sun you state +(`Sky::Stated`). + +```rust +use std::path::Path; +use lumenwright::glam::DVec3; +use lumenwright::render::{BackendKind, Mode}; +use lumenwright::scene::camera::Camera; +use lumenwright::world::Dimension; +use lumenwright::*; + +let camera = Camera { pos: DVec3::new(18.5, 73.6, 22.5), yaw: 150.0, pitch: 18.0, fov_y: 70.0 }; +let frame = lumenwright::render(&Request { + world: WorldSource::Dir { path: Path::new("saves/MyWorld"), dimension: Dimension::Overworld }, + chunks: ChunkRect::around(camera.pos, 8), + jar: Path::new("minecraft-1.21.11-client.jar"), + unifont: None, + resource_packs: &[], + camera, + mode: Mode::Quick, + backend: BackendKind::Auto, + width: 1280, + height: 720, + entities: EntityPolicy::default(), + quick: QuickOptions::default(), + hq: HqOptions::default(), + sky: Sky::default(), +})?; +// frame.rgba: RGBA8, top row first. frame.backend, frame.backend_fallback, +// frame.timings, frame.entity_stats, frame.block_entity_stats: what was drawn and how. +``` + +`render` does not look for a jar, use the network, write files or print. When it refuses a +request before drawing (a missing jar, an empty image, no chunks in the rectangle), the +error holds a `RequestError` you can match with `err.downcast_ref::()`. On the +CPU backend the same request gives the same bytes whatever the thread count. + +The library crate holds no command-line code, no jar resolver and no downloader. Its default +feature, `gpu`, adds the Metal, Vulkan and DX12 backends; `cuda` adds the CUDA backend. With +the default features it compiles no C code, so it cross-checks for Linux, macOS and Windows +targets without a cross C compiler. The `lumenwright render` command calls the same +function, so the command and the library draw the same pixels. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..7e5abf6 --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,32 @@ +# Known limitations + +[Lumenwright](../README.md) + +## Known limitations (v0.1) + +See [docs/SCOPE.md §3](SCOPE.md). The main gaps: +* Beds, shulker boxes and the conduit are not drawn. Chests are drawn closed and bells at + rest; sign text is drawn without italic, underline or obfuscation. +* Items that vanilla draws in Java code are not drawn, the shield excepted: chests, beds, + banners, heads, shulker boxes, decorated pots, the conduit and copper golem statues. They + are counted in `items_skipped`. A filled map in a frame shows the map item, not its + picture; armour trims and the elytra are not drawn. +* Entity layers that need a second texture are missing (sheep wool, glowing eyes). +* Smooth fluid surfaces, clouds, weather and biome blending are not implemented. In quick + mode the End's sky box is drawn as its fog colour, the dark disc vanilla draws below the + horizon for a camera under sea level is not drawn, and the lightmap's daylight still + follows the pre-1.21.11 day curve. +* HQ mode lights emitters as Chunky does, and Chunky's emitter sampling is not a light + transport estimate: it picks one emitter near the surface without dividing by the pick's + probability, so a surface among many emitters gets the average of their light rather + than the sum, and an emitter farther than 10 to 19 blocks along an axis (by where the grid + cells fall) is never sampled; it lights a surface only through bounced paths that meet it. +* Not yet ported from Chunky: biome blending, candle and campfire flame particles, its + fancier translucency, water shading, metals and its Nishita sky + ([docs/research/chunky-parity.md](research/chunky-parity.md)). +* Chunky has no time of day. Under `--sky chunky` HQ mode keeps Chunky's sky while any part + of the sun's disc is above the horizon; below it, the sky falls to a night's 3% at the end + of civil twilight (the sun 6° below the horizon), and the sun gives no light from below the + horizon. Those two rules are Lumenwright's own. The default game sky has none. +* The game's sky draws no clouds and no weather, and HQ mode's lanterns are Chunky's emitters, + not the game's block light ([docs/research/game-sky.md](research/game-sky.md)). diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..8f59618 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,20 @@ +# Releasing + +[Lumenwright](../README.md) + +## Releasing + +Releases are published by CI only, never from a workstation. The `release` workflow +(`.github/workflows/release.yml`) is run by hand on `main` with the version to release. It +refuses unless that version is the version of all three crates and none is marked +`publish = false`, `main`'s CI succeeded at the commit, none of `lumenwright-kernel`, +`lumenwright` and `lumenwright-cli` has that version on crates.io, no tag `v` +exists, and `CHANGELOG.md` has a dated `## [] - YYYY-MM-DD` section (a section still +marked Unreleased is refused). It then runs the tests, publishes `lumenwright-kernel`, +`lumenwright` and `lumenwright-cli` in that order, each once the crates.io index serves the +one it depends on, with the repository secret `CARGO_REGISTRY_TOKEN` (a crates.io API token +for the three crates), and tags `v` with a GitHub Release whose notes are that +changelog section. + +Before a release, `cargo publish --dry-run --workspace` packages the three crates and builds +each from its package against the others as packaged; it needs no token. diff --git a/docs/renders.md b/docs/renders.md new file mode 100644 index 0000000..5edef8c --- /dev/null +++ b/docs/renders.md @@ -0,0 +1,115 @@ +# The Treehouse Camp renders + +[Lumenwright](../README.md) + +## How they are drawn + +HQ mode runs with its defaults, which go by the time of day. At `--time 4000` the day +frames are Chunky's look: the Preetham sky, a sun of intensity 1.25 that lights and shadows, +lanterns and campfires as emitters at intensity 13 with one emitter sampled per diffuse +bounce, and the display gamma 2.2. + +At `--time 12800` the sun stands just below the horizon. Quick mode draws the client's sunset: +the sun, the orange glow and the light the game stored in the save. HQ mode draws mostly the +game's look (Chunky's at a weight of 0.18, by where the client's sky light factor stands): +the client's sky and its evening light, no sunlight, the lanterns at the game look's +intensity 137.96, fitted to Delvewright's engine's render of this frame, and the tone curve +that shows mid-grey as the game does (see [docs/research/game-sky.md](research/game-sky.md)). + +Each image's command, run from the repository root with the save from +[Get the camp](#get-the-camp) in `the-treehouse-camp`. The HQ commands need the CUDA build +(see [Backends](backends.md#backends)); on another backend the same flags give the same image within the +parity the tests hold (`gpu_matches_cpu_in_hq_mode`, `cuda_matches_cpu_in_hq_mode`). + +```sh +# The whole camp +target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ + --time 4000 --backend metal -o treehouse-camp-day-quick.png +target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ + --time 4000 --backend cuda --mode hq --spp 4096 --max-bounces 8 -o treehouse-camp-day-hq.png +target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ + --time 12800 --backend metal -o treehouse-camp-dusk-quick.png +target/release/lumenwright render -w the-treehouse-camp --camera 140,118,14 --look-at 56,82,58 --fov 60 --render-distance 12 \ + --time 12800 --backend cuda --mode hq --spp 16384 --max-bounces 8 -o treehouse-camp-dusk-hq.png + +# The Long Bridge +target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ + --time 4000 --backend metal -o treehouse-bridge-day-quick.png +target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ + --time 4000 --backend cuda --mode hq --spp 4096 --max-bounces 8 -o treehouse-bridge-day-hq.png +target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ + --time 12800 --backend metal -o treehouse-bridge-dusk-quick.png +target/release/lumenwright render -w the-treehouse-camp --camera 60,93,22 --look-at 60,80,42 --fov 70 \ + --time 12800 --backend cuda --mode hq --spp 16384 --max-bounces 8 -o treehouse-bridge-dusk-hq.png +``` + +| image | Apple M2 Pro, Metal (end to end) | RTX 4070 Ti SUPER, CUDA (end to end / in the kernel) | +|---|---|---| +| camp, quick, day / dusk | 0.27 s / 0.27 s | | +| camp, hq, day (4096 spp) / dusk (16384 spp) | | 91 s / 333 s (90 s / 332 s) | +| bridge, quick, day / dusk | 0.23 s / 0.23 s | | +| bridge, hq, day (4096 spp) / dusk (16384 spp) | | 122 s / 447 s (121 s / 445 s) | + +These are the renders' own times at their quality budgets, not a speed comparison (that is +[benchmark.md](benchmark.md)). The quick times are the second of two runs, measured at `v0.1.0`; +the quick frames are byte-identical at this revision. The 4070 Ti SUPER runs under WSL2 on a 16-thread PC. +Each HQ image rendered twice with the same command on the CUDA backend gave byte-identical +PNGs, as each quick image did on Metal. + +### Get the camp + + +The camp ships as a container image that runs the map's own server. The server builds the +camp over its first ticks. These commands wait for it to finish, then save the world and copy +it out. They need Docker. `EULA=TRUE` accepts [Mojang's EULA](https://aka.ms/MinecraftEULA) +for the server inside the container. + +```sh +docker run -d --name treehouse-camp -e EULA=TRUE ghcr.io/stellarfeline/delve-the-treehouse-camp:v1.0.1 +# The camp is stamped by its datapack over the first ticks; wait until it says so. +until docker exec treehouse-camp rcon-cli scoreboard players get '#placed' dw.sys 2>/dev/null | grep -q 'has 1'; do sleep 5; done +docker exec treehouse-camp rcon-cli save-all flush +docker stop treehouse-camp +docker cp treehouse-camp:/data/world the-treehouse-camp +docker rm treehouse-camp +``` + +The image is release v1.0.1 of the camp, built by `delvec` 1.12.0 from +`campaigns/the-treehouse-camp/` in +[stellarfeline/delvewright-campaigns](https://github.com/stellarfeline/delvewright-campaigns) +at the release's tag. The campaign's `GENERATION.md` there records how it was built and +checked. The save is written by a running server, so it is not byte-identical from one run +to the next: the same frame of two saves from two server runs differed in 257 of its 921,600 pixels. + +### Render + + +```sh +# Inspect a save +target/release/lumenwright info the-treehouse-camp --entities + +# List the block entities (and sign text) in a box of x,z block coordinates +target/release/lumenwright info the-treehouse-camp --block-entities=0,0,112,112 + +# Vanilla-look render from the save's spawn, at the save's own time of day +target/release/lumenwright render -w the-treehouse-camp -o quick.png + +# Path-traced render from an explicit camera, under the game's sky at the hour +target/release/lumenwright render -w the-treehouse-camp --mode hq --spp 256 \ + --camera 140,118,14 --look-at 56,82,58 --time 12800 -o hq.png + +# The same under Chunky's sky, with a sun that lights and shadows +target/release/lumenwright render -w the-treehouse-camp --mode hq --spp 256 --sky chunky \ + --camera 140,118,14 --look-at 56,82,58 --sun-altitude 35 --sun-azimuth 120 -o hq-chunky.png + +# Generated worlds, no download: a small showcase (terrain, cottage, lake, mobs) +target/release/lumenwright demo-world showcase +target/release/lumenwright render -w showcase --mode hq -o showcase.png + +# One of every block entity Lumenwright draws (docs/renders/block-entities-quick-metal.png) +target/release/lumenwright demo-world --block-entities gallery +target/release/lumenwright render -w gallery \ + --camera 9.5,70.5,11.5 --look-at 9.5,65.4,-2 --fov 60 -o gallery.png +``` + +`lumenwright backends` lists the backends available on the current machine. diff --git a/docs/renders/treehouse-bridge-dusk-hq.png b/docs/renders/treehouse-bridge-dusk-hq.png index 292aa5b..54816ce 100644 Binary files a/docs/renders/treehouse-bridge-dusk-hq.png and b/docs/renders/treehouse-bridge-dusk-hq.png differ diff --git a/docs/renders/treehouse-camp-dusk-hq.png b/docs/renders/treehouse-camp-dusk-hq.png index 5e1ea67..cd55671 100644 Binary files a/docs/renders/treehouse-camp-dusk-hq.png and b/docs/renders/treehouse-camp-dusk-hq.png differ diff --git a/docs/research/chunky-parity.md b/docs/research/chunky-parity.md index 2eb742e..7f8f900 100644 --- a/docs/research/chunky-parity.md +++ b/docs/research/chunky-parity.md @@ -7,6 +7,11 @@ was made from: Chunky 2.5.0-SNAPSHOT.474 is `chunky-core-2.5.0-SNAPSHOT.474.g156 chunky-dev/chunky at `156e2bba2526` (2026-07-26). Paths below are under `chunky/src/java/se/llbit/` at that revision. +This page covers Chunky's sky and sun, which HQ mode draws under `--sky chunky`. Its +default, the game's own sky lit as the client lights the tick, is +[game-sky.md](game-sky.md); the rest of the path tracer (emitters, faces, water, the display +gamma) is the same under both. + Each statement is marked **[cited]** (the Chunky source or the paper it cites says it), **[measured]** (a render or a test shows it) or **[inferred]**. @@ -157,11 +162,12 @@ machine's monotonic clock. block's own biome. Visible only at biome borders. * Candle and campfire flame particles, fancier translucency, Chunky's water shading and its metals: not ported, and not examined in the benchmark frames. -* Twilight and night: Chunky has no time of day, and its Preetham sky keeps the sunset sky - at any sun below the horizon. Below the sun's disc, Lumenwright's sky falls to a night's 3% - at the end of civil twilight and its sun gives no light from below the horizon; frames with - the sun more than its radius below the horizon are Lumenwright's own. Chunky's Nishita sky, - which its documentation names for twilight, is not ported. +* Twilight and night under `--sky chunky`: Chunky has no time of day, and its Preetham sky + keeps the sunset sky at any sun below the horizon. Below the sun's disc, Lumenwright's + Chunky sky falls to a night's 3% at the end of civil twilight and its sun gives no light from + below the horizon; frames with the sun more than its radius below the horizon are + Lumenwright's own. Chunky's Nishita sky, which its documentation names for twilight, is not + ported. The default sky has a time of day: the client's ([game-sky.md](game-sky.md)). * Where the converged dusk frames still differ most (40-pixel blocks, 0.67/255 overall): the tree at the frame's left edge (x 0–40, y 520–640, a mean of 3.1/255; its trunk darker and its leaves lighter in Lumenwright's frame) [measured]; the cause is not examined (the leaf diff --git a/docs/research/game-sky.md b/docs/research/game-sky.md new file mode 100644 index 0000000..7103a0d --- /dev/null +++ b/docs/research/game-sky.md @@ -0,0 +1,194 @@ +# HQ mode's game sky: the light and the sky of the client at a tick + +HQ mode's game sky (`--sky game`, `SkyLook::Game`) lights a frame as the 1.21.11 client +lights the same tick and shows the client's own sky. HQ mode's default (`--sky auto`, +`SkyLook::ByTime`) draws it at dusk and night and Chunky's sky and sun by day +([By the time of day](#by-the-time-of-day)). The method is the one Delvewright's +engine lights its Chunky scenes with (stellarfeline/delvewright at `0fd300da7`: +`crates/delvec/src/compiler/view/scene.rs`, `sky_image.rs`, and the record in its +`docs/reference/compiler.md` §4). Lumenwright ports the method, not a tuned constant: every +value is read from the client jar at the frame's tick. Chunky's Preetham sky and sun stay as +`--sky chunky` ([chunky-parity.md](chunky-parity.md)). + +The client read is the 1.21.11 jar (`client.jar` sha1 `ba2df812c2d12e0219c489c4cd9a5e1f0760f5bd`) +through Mojang's mappings (`client.txt` sha1 `031a68bebf55d824f66d6573d8c752f0e1bf232a`). +Each statement is marked **[cited]** (the client, Chunky or the engine says it), +**[measured]** or **[authored]** (Lumenwright's or the engine's own rule). + +## The method + +| What | Lumenwright | Source | +|---|---|---| +| The sun's light | none: the kernel draws no next-event sample for a sun of colour 0 | [cited] nothing in the client's lightmap depends on the sun's direction; a cell open to the sky is drawn at the lightmap's colour whatever the hour's sun, and no block casts a sun shadow | +| The light the sky casts | the colour `lightmap.fsh` gives sky light 15 and block light 0: `SkyFactor` = `visual/sky_light_factor`, `SkyLightColor` = `visual/sky_light_color` (both from the jar's day timeline, `EnvironmentState`), `AmbientLightFactor` 0, the `gamma` option's default 0.5 as `BrightnessFactor`; read at its 8-bit level (the lightmap is an RGBA8 texture) and decoded with `x^2.2` (`sky::lightmap_sky`) | [cited] the shader, `LightTexture.updateLightTexture` (reads exactly those two attributes), `Options` (`gamma` 0.5), `TextureFormat.RGBA8`; [authored, the engine's] reading the decoded colour as the radiance a top face is lit by: a Lambertian face open to a sky of mean radiance `L` returns `albedo × L`, so under the gamma transfer it shows `albedo_display × L_display`, the game's product | +| Where that light comes from | the client's sky with nothing drawn in it (no sun, moon or stars), times a scale, plus the least uniform veil that makes the cosine-weighted mean over the upper hemisphere exactly `L` in every channel; the scale is the largest up to 50 that keeps the veil non-negative (`sky::game_sky_maps`) | [authored, the engine's `sky_image::sky_map`]; the bound 50 is Chunky's `Sky.MAX_INTENSITY` [cited] | +| What the lens sees | the client's sky at the tick, as quick mode draws it (`SkyRenderer`, `AtmosphericFogEnvironment.getBaseColor`): the clear colour, the fogged sky disc, the sunrise fan, the sun (size 30), this night's moon (size 20) and the stars, decoded with `x^2.2` | [cited] the client; Lumenwright's quick sky | +| The clear colour's lean to the sky colour | `1 − Mth.clampedLerp(min(sky fog end / 16, render distance) / 32, 0.25, 1)^0.25`; `clampedLerp(delta, start, end)`: 0.146 at 12 chunks | [cited] `getBaseColor` and `Mth.clampedLerp` bytecode | +| The sun drawn | at every altitude, as `renderSunMoonAndStars` draws it (no altitude test) | [cited] bytecode | +| The tone curve | Chunky's Hable preset read `k` = 1.463761 times brighter, so linear 0.18 shows at `0.18^(1/2.2)` (`--tonemap filmic-mid-grey`, the default under the game's sky) | [cited] Chunky's `HableToneMappingFilter` preset and arithmetic, the 18% grey card; [authored, the engine's] anchoring the curve at mid-grey, and the closed form for `k` | +| The render distance the sky is drawn for | `--render-distance` (chunks), as quick mode; the client's default 12 in the library | [cited] the client reads it in `getBaseColor` and the sky fog | +| The maps | the lens's 2048 × 1024 and the light's 360 × 180, equirectangular, bilinear, in the kernel's `sky` buffer on every backend (`integrator::sky_lens`, `sky_light`, `Params::sky_map`) | [authored] | + +A camera ray, and a path that has met only glass and water, sees the lens's map; a path that +has bounced off a diffuse surface takes the light's. Chunky cannot set the two apart (its sky +modes feed one colour to the lens and to the light, scaled by `apparentSkyLight` or `skyLight`, +`Sky.getSkyDiffuseColorInner`), so the engine's lens sees the veil too. + +## Where Lumenwright departs from the engine + +* **The lens sees no veil** [authored]. The veil is the price of Chunky's one map: at noon, + under a white light and a blue sky, it is the light's yellow remainder and pales the + engine's zenith. Lumenwright's kernel reads two maps, so the lens shows the client's sky. +* **The clear colour leans 0.146 to the sky colour, not 0.218** [cited]. The engine writes + `1 − clamp(min(512/16, 12)/32, 0.25, 1)^0.25`; the client's bytecode calls + `Mth.clampedLerp(x/32, 0.25, 1)`, whose arguments are `(delta, start, end)` + (`delta < 0` gives `start`, `delta > 1` gives `end`, else `lerp(delta, start, end)`), so the + weight is `1 − (0.25 + 0.75 × 0.375)^0.25`. Lumenwright's quick sky already read it so; the + engine's sky map, and the light it derives from it, lean too far toward the sky colour. +* **The sun is drawn below the horizon** where a ray reaches the sky there, as the client + draws it [cited]; the engine draws Chunky's sun from −4.29° (the disc's half-width) up. +* **The sun, moon and stars are the client's** [cited]: the size-30 sun quad blended into the + sky as the client blends it, the moon and the 1500 stars. The engine's renderer has no moon + or stars and draws Chunky's own sun texture (a smaller square at brightness 16.3). +* **The lightmap is read at its 8-bit level** [cited]; the engine reads the shader's value. + At tick 12800 the two differ by 0.6% in the light. +* **The lantern scale is Lumenwright's own fit.** The engine writes `emitterIntensity` + 159.003, Chunky's 13 times 12.2310, fitted so that the mean display luma of the Treehouse + Camp's lantern-lit pixels equals a game model's. Lumenwright fits its own against the + engine's render ([Lanterns](#lanterns)). + +## By the time of day + +The default look follows the client's day [cited for the quantity, authored for the mix]. On +the 1.21.11 day timeline three tracks mark one day plateau, from tick 730 to tick 11270: +`visual/sky_light_factor` and `visual/sky_light_color` hold their day values (1 and white) +and `visual/sunrise_sunset_color` its zero alpha. Off the plateau the factor falls linearly +to its night value 0.24 at 13140, holds it to 22860, and rises back by 730. The weight of +Chunky's look is where the factor at the tick stands between those two values, +`(factor − 0.24) / (1 − 0.24)` (`sky::day_weight`; the two values are read from the jar's +timeline by `environment::sky_light_factor_range`): 1 on the day plateau, 0 on the night +plateau, linear in ticks on the ramps, at most 1/1870 a tick. At the camp's dusk, tick +12800, it is 0.181818. + +The two looks are mixed in one render: Chunky's sky, read through its cache, is weighted +into the game's two maps, and Chunky's sun lights and is drawn at the same weight. Light +transport is linear in the sky's and the sun's radiance, so the frame's radiance is the +weighted mix of the two looks' radiances. Each look keeps its tone curve and emitter +intensity unless the caller states one: the curves (gamma, filmic-mid-grey) are mixed in +display values and the intensities (13, 137.9628) linearly. On the day plateau the frame is +`--sky chunky`'s byte for byte; on the night plateau `--sky game`'s [measured, the test +`the_default_sky_follows_the_day_without_a_jump`]. One tick across either edge of the ramp +moves the camp frame by 0.067/255 (Metal, 64 spp, at `4d1f6bd`), as one tick inside Chunky's look does +(0.059); a hard switch at the plateau's edge moves it by 28.7 [measured]. + +## Lanterns + +The game look's emitter intensity is 137.9628, Chunky's 13 times 10.6125: **authored by +Lumenwright**, neither a vanilla nor a Chunky value (`request::GAME_EMITTER_INTENSITY`). +Chunky's emitters light little beyond their block where the game's block light reaches 14 +blocks; the scale stands in for that, as the engine's 12.2310 does. `--emitter-intensity` +takes any value over it. + +The fit [measured]: the whole-camp view at tick 12800 under the default sky (day weight +0.181818), CUDA, 16384 samples, four bounces, emitters sampled `ONE` (Lumenwright's +default), rendered at intensities 13 and 26 with one seed, which draw the same random +numbers, so the frame at any intensity `k` is `HDR(13) + (k − 13)/13 × (HDR(26) − HDR(13))` +in radiance (a direct render at the fitted `k` agrees with that to 6.75e-7). Lantern-lit +pixels are those where the emitters' light at the engine's intensity 159.003 outshines the +sky's (Rec. 709 luminance): 128 564 of 921 600. The objective is that their mean display +luma under the default tone mix equals the engine's Chunky render's, 41.993; bisection gives +`k` = 115.2423 (Lumenwright reads 28.622 there at 13 and 45.956 at 159.003), so the game +look's intensity is `(115.2423 − 0.181818 × 13) / 0.818182` = 137.9628. Per pixel the fitted +frame is 7.33 luma from the reference on average (rms 9.29); fitting each quarter of the frame +on its own gives 118.5, 114.6, 116.6 and 112.6. + +## Measurements + +The reference is Delvewright's engine's look: the whole-camp view of *The Treehouse Camp* +(1280 × 720, camera 140, 118, 14 looking at 56, 82, 58, vertical field of view 60°, the save +the README's renders use), rendered by Chunky 2.5.0-SNAPSHOT.474 (core `156e2bb`) at 2048 +samples per pixel with every key the engine writes about light and tone produced by the +engine's own functions at `0fd300da7` (`scene::sky_of`, `sky_image::sky_map` for this camera, +`scene::tone_curve`, `scene::emitter_intensity`, `EXPOSURE`, ray depth 5, emitter sampling +`NONE`). The engine's camera record cannot state these ticks (its hours are keywords and +celestial positions, and it no longer builds the released camp), so a one-file program linked +against the engine at that revision wrote the scene's keys for ticks 12800 and 4000 [measured: +at tick 12782 it writes the light `sky.color` (0.357581, 0.357581, 1), the value the engine's +`compiler.md` §4 records for the camp's setting sun]. No in-game screenshot of this camera +exists in the treehouse rounds' scratch directories. + +Lumenwright renders the same frame on CUDA at 16384 samples, four bounces, with the engine's +emitter settings (`--emitter-intensity 159.003 --emitter-sampling none`) so the comparison +isolates the sky and the tone. Each component is shown on the same samples: the HDR frame +re-shown through the other tone curve by a numpy port of `post::tonemap`, which reproduces +Lumenwright's own PNGs to within one 8-bit level on 7–17 of 921 600 pixels. Display: mean +absolute 8-bit difference over channels, and the 90th and 99th percentiles of each pixel's +largest channel difference. SSIM: Wang et al. 2004 on display luma, Gaussian window σ 1.5. +Sky hue: the per-channel median of a patch of sky picked by ray direction (sRGB), and its +HSV hue, as the engine reports it: *band*, 2–4° up and 12–25° left of the setting sun; *upper*, +6–9° up at the same bearing; *away*, 5–9° up and 60–73° left of the sun (the frame's left). +Lumenwright's noise floor: the dusk frame against itself with another seed is display mean +1.45, p90 7, p99 14, SSIM 0.9327 [measured]. + +**Dusk** (tick 12800): + +| frame | display mean | p90 | p99 | SSIM | mean luma | band | upper | away | +|---|---|---|---|---|---|---|---|---| +| Chunky, the engine's light (reference) | | | | | 54.2 | (178, 97, 98) 359° | (166, 96, 103) 354° | (151, 95, 110) 344° | +| main (`38ef002`): Chunky's sky and sun, `gamma` | 18.68 | 124 | 156 | 0.7799 | 73.5 | (249, 221, 123) 47° | (230, 217, 147) 51° | (147, 145, 115) 56° | +| main + the mid-grey tone curve | 14.25 | 90 | 106 | 0.7946 | 68.7 | (202, 188, 122) 50° | (193, 186, 141) 52° | (142, 140, 115) 56° | +| the game's sky, `gamma` | 9.72 | 40 | 126 | 0.8103 | 56.4 | (205, 94, 57) 15° | (186, 92, 66) 13° | (168, 92, 76) 10° | +| the game's sky and the mid-grey curve (`f291590`, the defaults) | 7.12 | 35 | 86 | 0.8125 | 56.0 | (180, 96, 60) 18° | (168, 95, 69) 16° | (157, 94, 78) 12° | + +**Day** (tick 4000): + +| frame | display mean | p90 | p99 | SSIM | mean luma | band | upper | away | +|---|---|---|---|---|---|---|---|---| +| Chunky, the engine's light (reference) | | | | | 105.8 | (213, 212, 204) 53° | (210, 210, 204) 60° | (212, 211, 204) 52° | +| main (`38ef002`): Chunky's sky and sun, `gamma` | 30.84 | 65 | 77 | 0.7267 | 113.4 | (154, 172, 185) 205° | (139, 172, 196) 205° | (147, 175, 195) 205° | +| main + the mid-grey tone curve | 33.17 | 68 | 75 | 0.7416 | 110.3 | (147, 159, 168) 206° | (135, 159, 174) 203° | (142, 161, 174) 204° | +| the game's sky, `gamma` | 9.67 | 51 | 51 | 0.9348 | 102.4 | (182, 208, 255) 219° | (167, 196, 255) 220° | (176, 203, 255) 219° | +| the game's sky and the mid-grey curve (`f291590`, the defaults) | 8.37 | 47 | 55 | 0.9321 | 98.3 | (166, 181, 204) 216° | (156, 175, 204) 216° | (162, 179, 204) 216° | + +**The defaults at `0cd2b54`** (by the time of day, the fitted lanterns, no emitter flags; +CUDA, 16384 samples, four bounces), against the same references [measured]: + +| frame | display mean | p90 | p99 | SSIM | mean luma | band | upper | away | +|---|---|---|---|---|---|---|---|---| +| dusk (day weight 0.181818, emitter intensity 115.2423) | 8.06 | 33 | 86 | 0.8162 | 59.6 | (190, 127, 77) 27° | (178, 125, 90) 24° | (156, 105, 87) 16° | +| day (Chunky's look, emitter intensity 13) | 31.46 | 65 | 79 | 0.7161 | 111.8 | (154, 172, 185) 205° | (139, 172, 196) 205° | (147, 175, 195) 205° | + +By day the default is Chunky's look by ruling, not the engine's, so the day reference is a +record of the difference and not a target. At dusk the lantern-lit pixels read 41.993, as +the reference's (the fit); the frame's mean luma is above the reference's because Chunky's +sky enters at 0.18 and Lumenwright's lens shows the client's sky without the veil. + +At the `gamma` step the reference is the same Chunky render re-shown with `GAMMA`: dusk display +mean 17.67 (main) and 7.78 (the game's sky), day 42.38 and 12.01. + +* [measured] The two departures account for what is left in the sky. A build of `a86e929` + with both turned back to the engine's choices (the lens's map carrying the veil, and the clear + colour's weight `1 − clamp(x, 0.25, 1)^0.25`), on Metal at 256 samples, reads the sky patches + at dusk (178, 99, 100) 359°, (166, 97, 105) 353°, (151, 96, 111) 344° and by day + (213, 213, 204) 60°, (210, 210, 204) 60°, (211, 212, 204) 68°: the reference's to within + three levels; its mean luma is 55.3 and 106.0 against 54.2 and 105.8, and by day its frame + is display mean 3.38 from the reference. With the client's lean and no veil in the lens, the + dusk sky is the orange of the client's sunset fan (hue 12–18°) where the reference is mauve + (344–359°), and the day sky the client's blue (216°) where the reference is the veil's + near-white. +* [measured] At dusk the light the sky casts, peak channel, is 0.368591 here (the lightmap + at its 8-bit level) and 0.36634 in the engine's scene: 0.6%. +* [measured] The day frames' SSIM (0.93) is near the noise floor's; at dusk (0.81) the + difference is the sky (the hues above) and the lanterns' noise under emitter sampling + `NONE`, which Chunky's frame averages over ten paths per sample (`branchCount` 10). + +## Not expressed + +* Clouds, and the weather: Lumenwright's environment sampler applies no rain or thunder + layer, so every frame is drawn clear. +* The dark disc the client draws under a camera below the world's horizon height. +* A face that sees open sky under the horizon (the side of a block over the void) takes light + from the clear colour there, warm on the sun's side, which the game does not cast: the light + is exact on an open top face (the engine's record says the same of its scenes). +* Quick mode's lightmap still takes its daylight from the pre-1.21.11 day curve; HQ mode's + game sky takes it from the timeline's `sky_light_factor` and `sky_light_color`. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..104cd2e --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,34 @@ +# Testing + +[Lumenwright](../README.md) + +## Testing + +```sh +cargo test --workspace --release +``` + +* **Unit tests** cover region and NBT decoding, the LZ4 framing, palette unpacking, multipart + rules, model baking orientation and UVs, the camera convention, sky and day cycle, the + environment attribute layering, mip chains (the client's lookup tables and cutout + coverage), quick-mode texture sampling, the lightmap, the tonemapper, and entity box-UV + and orientation. +* **Kernel tests** use hand-built buffers to check brick skipping, uniform bricks, entity + overlay cells, cutout alpha, one-sided faces met far along a ray, coplanar faces, the + sky, Chunky's emitter sampling against its expectation, and pinned RNG values; the kernel's + `f32` `ln`, `acos` and gamma decoding are held to bounds against `f64`. A unit test holds + the kernel's sky to Chunky's Preetham sky transcribed in `f64`. +* **Integration tests** (in `lumenwright-cli`, which owns the fixture worlds) write fixture + worlds and render them end to end. They check terrain + colours seen from above, entity coverage, block entities in both modes, sign text, items + in both modes (every holder kind, each item's spot against the same frame with the + holders empty), that every item definition in the jar resolves, HQ sanity, CPU-vs-GPU + parity in both modes (showcase, block-entity gallery and item gallery) and CPU-vs-CUDA + parity in HQ mode, determinism, and the CLI. The library tests check that the CLI's PNG + and the library's pixels are byte-equal in both modes, that a missing jar is refused by + name, that a stated sky is drawn as given, that the vanilla sky's colour at noon, dusk and + midnight matches the jar's day timeline within 2/255, and that CPU pixels are + bit-identical on 1, 3 and 8 threads (showcase and item gallery). A far-plane test measures + the high-frequency energy and the change under a sub-pixel camera move with and without + mipmaps. Tests that need the + client jar or a GPU skip with a message when it is missing. `LUMENWRIGHT_MC_JAR` points the tests at a jar. diff --git a/docs/textures.md b/docs/textures.md new file mode 100644 index 0000000..0e74a6b --- /dev/null +++ b/docs/textures.md @@ -0,0 +1,32 @@ +# Textures + +[Lumenwright](../README.md) + +## Textures: the Chunky approach + +Mojang's assets are not redistributable, so Lumenwright ships none. Like Chunky, it reads +textures, blockstates and block models straight out of the official client jar. The jar is +resolved in this order: + +1. `--minecraft-jar ` +2. A launcher's copy for the world's version: the vanilla launcher's + `versions//.jar`, Prism Launcher's library, or Lumenwright's own cache +3. Chunky's `~/.chunky/resources/minecraft.jar`, if its version matches +4. A download from Mojang's version manifest (`piston-meta.mojang.com`), SHA-1 verified, into + the cache (`~/Library/Caches/lumenwright` or `$XDG_CACHE_HOME/lumenwright`). `--offline` + disables this; `lumenwright assets fetch ` downloads ahead of time. + +This resolution and the downloader belong to the command. The library takes the jar as a path. + +The jar's own `font/include/unifont.json` lists no providers: the client reads the Unifont that +draws CJK and every other character no bitmap font covers from the launcher's asset index. The +command fetches it the same way, in this order: `--unifont ` (a directory holding +`minecraft/font/include/unifont.json` and the hex zip it names, laid out like the index's keys), +then the cache (`/unifont/`, every file checked against the SHA-1 its lock +records), then a download through the version's asset index, the index and every file checked by +SHA-1 and size. `--offline` forbids the download and refuses, naming what is missing, when +neither of the first two has the files. `lumenwright assets fetch-unifont ` +downloads ahead of time. The library takes the directory as `Request::unifont` and does the same +checks on it without any network. + +`--resource-pack` (zip or directory, repeatable) layers packs on top of the vanilla assets.