diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d44c1f5aea..098c19f789 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,14 +234,15 @@ jobs: run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe - name: Check launcher DLL imports and command-line startup working-directory: launcher - shell: pwsh + # bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh + # `$output = & launcher --version` read no output from it (run 42217014588). + shell: bash run: | - $ErrorActionPreference = 'Stop' - $launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe' - python ../tools/check_imports.py $launcher - $output = & $launcher --version 2>&1 - if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' } - Write-Output ($output -join "`n") + launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe + python ../tools/check_imports.py "$launcher" + out=$("$launcher" --version) + echo "$out" + [[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; } - name: Build a native-mod game with the launcher's managed toolchain working-directory: launcher shell: pwsh diff --git a/AGENTS.md b/AGENTS.md index 6cd0793d48..9c7cda140b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se | `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` | | `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) | | `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths | -| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay | +| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay | | `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage | | `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) | | `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` | @@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se | `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) | | `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) | | `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) | +| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) | | `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry | | `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu | | `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website | @@ -92,8 +93,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an ```sh python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR python3 tools/pr_ready.py --quick # same without harnesses -cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately -cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change +cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately +cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job) python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them @@ -105,8 +106,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end - Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`, `tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the harnesses for CI. -- A harness = `tools/test__parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`) - + a Rust probe: `crates//examples/_{probe,fixture}.rs` (horizon-world 175, horizon-client 104), +- A harness = `tools/test__parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`) + + a Rust probe: `crates//examples/_{probe,fixture}.rs` (mostly horizon-world and horizon-client), or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`, `game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one. - Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate @@ -176,7 +177,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex ## How `Game` is organised -- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps +- `struct Game` (game.rs) holds everything. Its methods are spread over many files: game.rs keeps the core (tick, render, input, inventory, world open/close) and each `game/.rs` adds an `impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests. - Find a feature's code: `grep -rn "fn tick_\|fn _" crates/horizon-client/src/game*`; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eff69b4ba1..f56913d72e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon, -and compares their outputs. There are about 290 of them. +and compares their outputs. - **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode: `python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`. diff --git a/Cargo.lock b/Cargo.lock index c36265b38b..9c1f62bb23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,9 +131,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "log", @@ -142,7 +142,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -153,6 +153,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -540,6 +547,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -946,6 +959,18 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1135,6 +1162,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gilrs" version = "0.11.2" @@ -1264,6 +1301,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1365,6 +1411,7 @@ dependencies = [ "ab_glyph", "bytemuck", "chrono", + "crc32fast", "dunce", "flate2", "fs4", @@ -1447,6 +1494,7 @@ dependencies = [ "ab_glyph", "bytemuck", "chrono", + "crc32fast", "flate2", "fs4", "getrandom 0.3.4", @@ -1603,7 +1651,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "socket2 0.5.10", + "socket2", "spake2", ] @@ -1660,10 +1708,15 @@ dependencies = [ name = "horizon-mod-deps" version = "0.1.0" dependencies = [ + "gif", "mysql", + "quinn", "redis", + "rusqlite", "rusty-opus", + "snap", "tungstenite", + "yaml-rust2", ] [[package]] @@ -1703,6 +1756,14 @@ dependencies = [ "regex", ] +[[package]] +name = "horizon-polytone" +version = "0.1.0" +dependencies = [ + "regex", + "serde_json", +] + [[package]] name = "horizon-port-pack" version = "0.1.0" @@ -1742,6 +1803,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "socket2 0.5.10", ] [[package]] @@ -2001,12 +2063,12 @@ dependencies = [ [[package]] name = "if-addrs" -version = "0.13.4" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2076,7 +2138,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.5", + "socket2", "widestring", "windows-registry", "windows-result 0.4.1", @@ -2268,6 +2330,17 @@ dependencies = [ "redox_syscall 0.9.4", ] +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -2323,6 +2396,12 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "lz4_flex" version = "0.14.0" @@ -2432,7 +2511,7 @@ dependencies = [ "percent-encoding", "rustls", "rustls-pemfile", - "socket2 0.6.5", + "socket2", "twox-hash", "url", "webpki", @@ -2450,6 +2529,7 @@ dependencies = [ "btoi", "byteorder", "bytes", + "chrono", "crc32fast", "flate2", "getrandom 0.3.4", @@ -2484,7 +2564,7 @@ dependencies = [ "num-traits", "once_cell", "pp-rs", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.21", "unicode-ident", @@ -2498,7 +2578,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" dependencies = [ "hashbrown 0.17.1", "indexmap", - "rustc-hash", + "rustc-hash 1.1.0", "thiserror 2.0.21", ] @@ -3354,6 +3434,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3401,6 +3537,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -3458,7 +3603,7 @@ dependencies = [ "itoa", "percent-encoding", "ryu", - "socket2 0.6.5", + "socket2", "url", "xxhash-rust", ] @@ -3539,12 +3684,32 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3619,6 +3784,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -3884,14 +4050,10 @@ dependencies = [ ] [[package]] -name = "socket2" -version = "0.5.10" +name = "snap" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" @@ -4198,13 +4360,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", ] [[package]] @@ -4274,6 +4446,9 @@ name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "tree_magic_mini" @@ -4412,6 +4587,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -4675,6 +4856,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "30.0.1" @@ -4728,7 +4915,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.21", "wgpu-core-deps-apple", @@ -5051,15 +5238,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5093,30 +5271,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -5138,12 +5299,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5156,12 +5311,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5174,24 +5323,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5204,12 +5341,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5222,12 +5353,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5240,12 +5365,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5258,12 +5377,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5471,6 +5584,16 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" +[[package]] +name = "yaml-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" +dependencies = [ + "arraydeque", + "hashlink", +] + [[package]] name = "yasna" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index dd772ba36f..a27a20b5ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-optifine", + "crates/horizon-polytone", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-deps", @@ -47,7 +48,7 @@ resolver = "2" [workspace.package] edition = "2024" license = "MIT" -rust-version = "1.85" +rust-version = "1.95" # Ports mirror vanilla's Java signatures, index loops and nested generic types # one to one so they can be checked against the decompiled sources. diff --git a/ROADMAP.md b/ROADMAP.md index 42e87aef46..ba7ed8233a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra -#### Ely.by and other sign-in providers - -Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft. - -
Technical - -Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)). - -
- #### Join servers on older versions Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4851955cc6..892791eec8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,15 @@ # Third-party notices +## Catnip / Ponder + +`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and +UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1 +(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team, +licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file. +The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The +development-only oracle calls the unchanged helper from the player's local artifact. No +original classes, game shaders, textures or prepared resources are distributed here. + ## wgpu-hal `third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs @@ -212,3 +222,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +## Just Enough Items + +The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`. + +## Flywheel native model directional shading + +The two-light diffuse expression and transformed-normal policy in +`terrain.wgsl`'s native model entries follow Flywheel 1.0.6 +(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`). +No Flywheel classes, compiled shaders or original model/texture assets are bundled. + +Copyright (c) 2021-2024 Jozufozu + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/cargo-horizon-mod/Cargo.toml b/crates/cargo-horizon-mod/Cargo.toml index c36e6fd56c..bbe318f070 100644 --- a/crates/cargo-horizon-mod/Cargo.toml +++ b/crates/cargo-horizon-mod/Cargo.toml @@ -3,7 +3,7 @@ name = "cargo-horizon-mod" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.86" +rust-version.workspace = true description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)" # `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive diff --git a/crates/horizon-anticheat/Cargo.toml b/crates/horizon-anticheat/Cargo.toml index 336626f347..b6c2159e7d 100644 --- a/crates/horizon-anticheat/Cargo.toml +++ b/crates/horizon-anticheat/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-anticheat" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.85" +rust-version.workspace = true description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)" [dependencies] diff --git a/crates/horizon-assets/examples/support/mod.rs b/crates/horizon-assets/examples/support/mod.rs index 0d0022baf6..4e8abd0b8d 100644 --- a/crates/horizon-assets/examples/support/mod.rs +++ b/crates/horizon-assets/examples/support/mod.rs @@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value { .collect(); json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission}) } -#[allow(dead_code)] // Shared by fixtures which normalize different model stages. +#[allow( + dead_code, + reason = "shared by fixtures which normalize different model stages" +)] pub fn model(v: &CuboidModel) -> Value { let textures: BTreeMap<_, _> = v .textures diff --git a/crates/horizon-assets/src/bake.rs b/crates/horizon-assets/src/bake.rs index d1ba340999..6ea74ad29e 100644 --- a/crates/horizon-assets/src/bake.rs +++ b/crates/horizon-assets/src/bake.rs @@ -34,6 +34,8 @@ pub struct ModelQuad { /// NeoForge's `render_type` names. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RenderType { + /// A declared render type unknown to this baker; distinct from an absent default. + Unsupported, Solid, Cutout, CutoutMipped, @@ -234,7 +236,10 @@ fn bake_into( skip_flat: bool, output: &mut Vec, ) -> Result<()> { - let render_type = model.render_type.as_deref().and_then(RenderType::by_name); + let render_type = model + .render_type + .as_deref() + .map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported)); for element in model.elements() { let element = element .as_ref() @@ -292,7 +297,10 @@ fn bake_obj( transform: BlockModelTransform, output: &mut Vec, ) -> Result<()> { - let render_type = model.render_type.as_deref().and_then(RenderType::by_name); + let render_type = model + .render_type + .as_deref() + .map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported)); for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) { let slot = match obj.materials.get(&face.material) { Some(MtlTexture::Sprite(sprite)) => { diff --git a/crates/horizon-assets/src/blockstate.rs b/crates/horizon-assets/src/blockstate.rs index e482aa66f7..698cbfbde6 100644 --- a/crates/horizon-assets/src/blockstate.rs +++ b/crates/horizon-assets/src/blockstate.rs @@ -179,19 +179,19 @@ impl Condition { let fields = object(raw)?; if fields.len() == 1 { for name in ["OR", "AND"] { - if let Some(raw) = fields.get(name) { - if let Ok(values) = array(raw).and_then(|values| { + if let Some(raw) = fields.get(name) + && let Ok(values) = array(raw).and_then(|values| { values .into_iter() .map(Self::decode) .collect::>>() - }) { - return Ok(if name == "OR" { - Self::Or(values) - } else { - Self::And(values) - }); - } + }) + { + return Ok(if name == "OR" { + Self::Or(values) + } else { + Self::And(values) + }); } } } diff --git a/crates/horizon-assets/src/resolve.rs b/crates/horizon-assets/src/resolve.rs index b39b89334d..9f724cff4f 100644 --- a/crates/horizon-assets/src/resolve.rs +++ b/crates/horizon-assets/src/resolve.rs @@ -777,6 +777,23 @@ mod tests { ); } #[test] + fn an_unknown_declared_render_type_remains_distinct_from_absent() { + let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material"); + let library = library(&[("block/custom", &json)]); + let model = library.resolve("block/custom").unwrap(); + let quads = crate::bake::bake_block_model( + &model, + crate::bake::BlockModelTransform::new([0; 3], false), + ) + .unwrap(); + assert!(!quads.is_empty()); + assert!( + quads + .iter() + .all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported)) + ); + } + #[test] fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() { let library = library(&[ ("block/base", r#"{"render_type":"translucent"}"#), diff --git a/crates/horizon-bench/Cargo.toml b/crates/horizon-bench/Cargo.toml index 5a18849900..a4a07d895d 100644 --- a/crates/horizon-bench/Cargo.toml +++ b/crates/horizon-bench/Cargo.toml @@ -2,6 +2,7 @@ name = "horizon-bench" version = "0.1.0" edition = "2021" +rust-version.workspace = true license = "MIT" description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload" diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index cb3e60f1b9..944ee5ac80 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m --seed N default 12345 --explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come) --pvp B fight bots also attack each other (true or false); default true + --crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every + other swing, landing it while falling: a critical hit, then a sweep from the + ground (give them swords); for PvP benches (crit and sweep particles) --name PREFIX bot names: PREFIX0, PREFIX1...; default Bot --brand B the client brand sent in configuration (minecraft:brand); default none --height H blocks above spawn the bots fly at (walk, explore, idle); default 24 @@ -165,6 +168,8 @@ struct Options { attack_after: f64, attack_rounds: u32, no_swing: bool, + /// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown. + crits: bool, flood: usize, no_read: bool, no_loaded: bool, @@ -202,6 +207,7 @@ fn options() -> Result { attack_after: 5.0, attack_rounds: u32::MAX, no_swing: false, + crits: false, flood: 100, no_read: false, no_loaded: false, @@ -230,6 +236,10 @@ fn options() -> Result { o.no_swing = true; continue; } + if arg == "--crits" { + o.crits = true; + continue; + } if arg == "--no-loaded" { o.no_loaded = true; continue; @@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() } +/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing. +const SWORD_COOLDOWN_TICKS: u32 = 13; +/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick. +const JUMP_POWER: f64 = 0.42; + +/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so +/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength), +/// and the next one from the ground (a sweep: on the ground, full strength, a sword). +#[derive(Default)] +struct Crits { + /// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98). + jump: Option, + ground: f64, + /// The next swing is the jumped one. + crit_next: bool, +} + +impl Crits { + fn airborne(&self) -> bool { + self.jump.is_some() + } + + /// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing. + fn tick( + &mut self, + pos: &mut [f64; 3], + target: (i32, [f64; 3]), + since: &mut u32, + ) -> Option { + if let Some(v) = self.jump { + pos[1] += v; + if pos[1] <= self.ground { + pos[1] = self.ground; + self.jump = None; + } else { + self.jump = Some((v - 0.08) * 0.98); + } + } else { + self.ground = pos[1]; + // A jump takes 6 ticks to its top: start it so the cooldown ends on the way down. + if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS { + self.jump = Some(JUMP_POWER); + } + } + let falling = self.jump.is_some_and(|v| v < 0.0); + if self.crit_next && !falling { + *since = since.saturating_add(1); + fight_step(pos, target.1); + return None; + } + let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS); + if hit.is_some() { + self.crit_next = !self.crit_next; + } + hit + } +} + /// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over. /// Returns the entity to attack. `since_attack` counts ticks since the last swing. -fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option { +fn fight_tick( + pos: &mut [f64; 3], + target: (i32, [f64; 3]), + since_attack: &mut u32, + cooldown: u32, +) -> Option { *since_attack = since_attack.saturating_add(1); let (id, at) = target; + fight_step(pos, at); + if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH { + *since_attack = 0; + return Some(id); + } + None +} + +/// Walk toward `at`, stopping `CLOSE_IN` short of it. +fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) { let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]); let horizontal = dx.hypot(dz); if horizontal > CLOSE_IN { @@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3 pos[0] += dx / horizontal * step; pos[2] += dz / horizontal * step; } - if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH { - *since_attack = 0; - return Some(id); - } - None } /// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second). @@ -616,7 +694,7 @@ fn costly( Behaviour::FloodUse => { // A block within reach: place stone there, or break what is there. let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3); - if i % 2 == 0 { + if i.is_multiple_of(2) { play::Serverbound::UseItemOn(play::UseItemOn { hand: 0, pos: at, @@ -645,7 +723,7 @@ fn costly( carried: None, }), Behaviour::FloodSuggest => { - let text = if i % 2 == 0 { + let text = if i.is_multiple_of(2) { "/" } else { LONG_COMMAND.as_str() @@ -1124,6 +1202,7 @@ fn run_bot( spawn[1] + o.height }; let mut since_attack = ATTACK_COOLDOWN_TICKS; + let mut crits = Crits::default(); let heading = random.unit() * std::f64::consts::TAU; let mut target = pos; let mut next = Instant::now(); @@ -1181,11 +1260,16 @@ fn run_bot( Behaviour::Fight => { let near = lock(&bot.entities).nearest(pos, o.pvp, &bots); if let Some(near) = near { - if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) { + let hit = if o.crits { + crits.tick(&mut pos, near, &mut since_attack) + } else { + fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS) + }; + if let Some(victim) = hit { // The client sends the attack, then swings the arm. let sent = send( play::Serverbound::Attack(play::Attack { id: victim }).encode(), - ) && send(raw(sb::PUNCH, |w| w.varint(0))); + ) && send(play::Serverbound::Punch(Empty).encode()); if sent { bot.attacks.fetch_add(1, Ordering::Relaxed); } @@ -1300,9 +1384,10 @@ fn run_bot( x: pos[0], y: pos[1], z: pos[2], - // Fight bots stand on the ground; the others fly. + // Fight bots stand on the ground (but in a --crits jump); the others fly. flags: u8::from( - matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground, + (matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne()) + || o.ground, ), }) .encode(), @@ -1470,10 +1555,10 @@ fn main() { }); if o.report.as_deref() == Some("-") { println!("{summary:#}"); - } else if let Some(path) = &o.report { - if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) { - eprintln!("horizon-bots: {path}: {e}"); - } + } else if let Some(path) = &o.report + && let Err(e) = std::fs::write(path, format!("{summary:#}\n")) + { + eprintln!("horizon-bots: {path}: {e}"); } let errors: Vec = bots .iter() @@ -1600,7 +1685,9 @@ mod tests { let target = (3, [1.0, 64.0, 0.0]); let mut since = ATTACK_COOLDOWN_TICKS; let hits: Vec = (0..40) - .filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick)) + .filter_map(|tick| { + fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick) + }) .collect(); assert_eq!(hits[0], 0, "a bot in reach strikes at once"); assert!( @@ -1617,7 +1704,7 @@ mod tests { let mut since = ATTACK_COOLDOWN_TICKS; let mut first = None; for tick in 0..100 { - if fight_tick(&mut pos, target, &mut since).is_some() { + if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() { first = Some(tick); break; } @@ -1627,6 +1714,43 @@ mod tests { assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9); } + #[test] + fn crits_alternate_a_falling_swing_and_a_grounded_one() { + let mut pos = [0.0, 64.0, 0.0]; + let target = (3, [1.0, 64.0, 0.0]); + let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default()); + let mut last_y = pos[1]; + let mut hits = Vec::new(); + for tick in 0..120u32 { + let hit = crits.tick(&mut pos, target, &mut since); + if hit.is_some() { + hits.push((tick, crits_state(pos[1], last_y))); + } + last_y = pos[1]; + } + assert!(hits.len() >= 6, "{hits:?}"); + for (i, w) in hits.windows(2).enumerate() { + assert!( + w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS, + "full strength each time: {hits:?}" + ); + assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}"); + } + // A crit lands in the air, on the way down; a sweep from the ground. + assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground")); + assert!(pos[1] >= 64.0); + } + + fn crits_state(y: f64, before: f64) -> &'static str { + if y == 64.0 { + "ground" + } else if y < before { + "falling" + } else { + "rising" + } + } + #[test] fn percentiles_use_nearest_rank() { assert_eq!(percentile(&[], 50), 0); diff --git a/crates/horizon-chat-filter/Cargo.toml b/crates/horizon-chat-filter/Cargo.toml index e9b8823ed8..9689a75866 100644 --- a/crates/horizon-chat-filter/Cargo.toml +++ b/crates/horizon-chat-filter/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-chat-filter" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.88" +rust-version.workspace = true description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)" [dependencies] diff --git a/crates/horizon-client-core/Cargo.toml b/crates/horizon-client-core/Cargo.toml index 8809a52164..d9574ed050 100644 --- a/crates/horizon-client-core/Cargo.toml +++ b/crates/horizon-client-core/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-client-core" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.90" +rust-version.workspace = true description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name" [dependencies] diff --git a/crates/horizon-client-core/src/registries.rs b/crates/horizon-client-core/src/registries.rs index 302051bf01..5f35ce8344 100644 --- a/crates/horizon-client-core/src/registries.rs +++ b/crates/horizon-client-core/src/registries.rs @@ -275,6 +275,25 @@ impl Registries { .clone_from(&e.advancement_criteria); } } + /// Add the damage catalog's types ([`Self::damage_types`]: content mods' + /// and the world's packs' with vanilla's) to the commands' + /// `minecraft:damage_type` ids, which the export lists for vanilla alone. + /// `ResourceArgument` looks a type up in the server's registries, so + /// `/damage` takes every type the world has. The export's tags stay. The + /// ids are replaced, not extended: `by_registry` outlives a world, and a + /// pack's type must not stay accepted in the next world opened. + pub fn sync_damage_type_ids(&mut self) { + if self.damage_types.is_empty() { + return; + } + let mut ids = self.damage_types.clone(); + ids.sort(); + ids.dedup(); + self.by_registry + .entry("minecraft:damage_type".into()) + .or_default() + .ids = ids; + } /// The element ids of a registry (sorted). pub fn ids(&self, registry: &str) -> &[String] { if let Some(r) = self.by_registry.get(registry) { @@ -312,3 +331,49 @@ impl Registries { } } } + +#[cfg(test)] +mod tests { + use super::Registries; + + /// A content mod's damage type joins vanilla's in the commands' ids, the + /// export's tags stay, and a second world open lists each type once. + #[test] + fn damage_type_ids_take_the_catalogs_types() { + let export = Registries::from_export(&serde_json::json!({ + "by_registry": {"minecraft:damage_type": { + "ids": ["minecraft:fall", "minecraft:magic"], + "tags": ["minecraft:is_fall"], + }}, + })); + let mut reg = Registries { + damage_types: vec![ + "minecraft:magic".into(), + "example:test".into(), + "minecraft:fall".into(), + ], + ..Registries::default() + }; + reg.fill_from_exported(&export); + assert_eq!( + reg.ids("minecraft:damage_type"), + ["minecraft:fall", "minecraft:magic"], + "the export alone lists vanilla's types" + ); + for _ in 0..2 { + reg.sync_damage_type_ids(); + assert_eq!( + reg.ids("minecraft:damage_type"), + ["example:test", "minecraft:fall", "minecraft:magic"] + ); + assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]); + } + // The next world, without the pack: its type is no longer accepted. + reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()]; + reg.sync_damage_type_ids(); + assert_eq!( + reg.ids("minecraft:damage_type"), + ["minecraft:fall", "minecraft:magic"] + ); + } +} diff --git a/crates/horizon-client-input/Cargo.toml b/crates/horizon-client-input/Cargo.toml index e1d7cf72bf..82eac9cdc7 100644 --- a/crates/horizon-client-input/Cargo.toml +++ b/crates/horizon-client-input/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-client-input" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.90" +rust-version.workspace = true description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options" [dependencies] diff --git a/crates/horizon-client-input/src/window.rs b/crates/horizon-client-input/src/window.rs index 891a0efec8..4848eea721 100644 --- a/crates/horizon-client-input/src/window.rs +++ b/crates/horizon-client-input/src/window.rs @@ -393,38 +393,17 @@ impl FullscreenState { let reapply = self.set_exclusive(exclusive); self.set_preferred_mode(preferred); let mode_change = !in_video_screen && self.take_mode_change(); - if wanted && window.is_maximized() { - window.set_maximized(false); - window.set_fullscreen(Some(fullscreen_request( + match request { + Some(false) => window.set_fullscreen(None), + Some(true) => enter_fullscreen( window, - exclusive, - preferred.as_ref(), - ))); - } else { - match request { - Some(false) => window.set_fullscreen(None), - Some(true) => { - if window.is_maximized() { - window.set_maximized(false); - } - window.set_fullscreen(Some(fullscreen_request( - window, - exclusive, - preferred.as_ref(), - ))); - } - None if reapply || mode_change => { - if window.is_maximized() { - window.set_maximized(false); - } - window.set_fullscreen(Some(fullscreen_request( - window, - exclusive, - preferred.as_ref(), - ))); - } - None => {} - } + fullscreen_request(window, exclusive, preferred.as_ref()), + ), + None if reapply || mode_change => enter_fullscreen( + window, + fullscreen_request(window, exclusive, preferred.as_ref()), + ), + None => {} } } @@ -602,6 +581,34 @@ fn monitor_current_mode( .max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth())) } +/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized +/// state first: winit keeps a maximized window's client area inside the work area, so a +/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984). +pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) { + #[cfg(target_os = "windows")] + if window.is_maximized() { + window.set_maximized(false); + } + window.set_fullscreen(Some(fullscreen)); +} + +/// Borderless fullscreen on the window's own monitor (the primary one when it has none). +pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen { + winit::window::Fullscreen::Borderless( + window + .current_monitor() + .or_else(|| window.primary_monitor()), + ) +} + +/// A window created fullscreen that the system also marked maximized enters it again +/// unmaximized ([`enter_fullscreen`]); only Windows does that. +pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) { + if cfg!(target_os = "windows") && fullscreen && window.is_maximized() { + enter_fullscreen(window, borderless_fullscreen(window)); + } +} + /// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes /// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop /// mode); a monitor with no usable mode falls back to borderless, as vanilla does. diff --git a/crates/horizon-client-lib/Cargo.toml b/crates/horizon-client-lib/Cargo.toml index c4543b069c..33e23bbd18 100644 --- a/crates/horizon-client-lib/Cargo.toml +++ b/crates/horizon-client-lib/Cargo.toml @@ -6,7 +6,7 @@ autotests = false version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.90" +rust-version.workspace = true description = "Reusable client systems, control tool and parity probes" [lib] @@ -76,6 +76,7 @@ horizon-theme = { path = "../horizon-theme" } # the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`). horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] } horizon-bench = { path = "../horizon-bench" } +crc32fast = "1" zip = { version = "9.1.3", default-features = false, features = ["deflate"] } # Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature. gilrs = { version = "0.11", optional = true } diff --git a/crates/horizon-client-render/Cargo.toml b/crates/horizon-client-render/Cargo.toml index 3ffa1d9a0f..2e61459245 100644 --- a/crates/horizon-client-render/Cargo.toml +++ b/crates/horizon-client-render/Cargo.toml @@ -3,7 +3,7 @@ name = "horizon-client-render" version = "0.1.0" edition.workspace = true license.workspace = true -rust-version = "1.90" +rust-version.workspace = true description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay" [dependencies] diff --git a/crates/horizon-client-render/examples/brewing_fuel_probe.rs b/crates/horizon-client-render/examples/brewing_fuel_probe.rs new file mode 100644 index 0000000000..7afd912c33 --- /dev/null +++ b/crates/horizon-client-render/examples/brewing_fuel_probe.rs @@ -0,0 +1,55 @@ +//! Headless production brewing-fuel component fixture. +use horizon_client_render::{ + brewing::{Brewing, BrewingStand, brewing_fuel}, + providers::Providers, +}; +use horizon_items::{ + item_data::decode_components, + items::{ItemStack, Items}, +}; +use horizon_world::loot::PackOverlay; +use std::{ + io::{BufRead, Write}, + path::Path, +}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let root = Path::new(&args[1]); + let items = Items::catalog(root, &PackOverlay::NONE)?; + let providers = Providers::load(root); + let brewing = Brewing { + recipes: vec![], + reagents: Default::default(), + inputs: Default::default(), + }; + let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?); + for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() { + let input: serde_json::Value = serde_json::from_str(&line?)?; + let mut stack = ItemStack::new( + input["item"].as_str().unwrap(), + input["count"].as_u64().unwrap() as u32, + ); + let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]); + let (_, extra) = decode_components(Some(&components)); + stack.extra = extra; + let accepted = brewing_fuel(&items, &stack).is_some(); + let mut stand = BrewingStand::new(); + // A newly constructed vanilla block entity has no saved total yet. + stand.total_fuel = 0; + stand.items[4] = Some(stack); + for _ in 0..2 { + stand.tick(&brewing, &items, &providers); + writeln!( + out, + "{} {} {} {} {}", + accepted, + stand.fuel, + stand.total_fuel, + stand.speed.to_bits(), + stand.items[4].as_ref().map_or(0, |s| s.count) + )?; + } + } + Ok(()) +} diff --git a/crates/horizon-client-render/examples/entity_shadow_ownership.rs b/crates/horizon-client-render/examples/entity_shadow_ownership.rs index a93455912a..a663c2d209 100644 --- a/crates/horizon-client-render/examples/entity_shadow_ownership.rs +++ b/crates/horizon-client-render/examples/entity_shadow_ownership.rs @@ -66,6 +66,8 @@ fn fixture(window: Arc) -> Result<(), Box> { false, false, true, + false, + false, ))?; assert!(!renderer.pack_entity_shadows(), "no selected pack"); renderer.set_shader_pack(0, None, &[]).unwrap(); diff --git a/crates/horizon-client-render/examples/scene_replay.rs b/crates/horizon-client-render/examples/scene_replay.rs index a70e61a8d0..eb141d8953 100644 --- a/crates/horizon-client-render/examples/scene_replay.rs +++ b/crates/horizon-client-render/examples/scene_replay.rs @@ -224,6 +224,8 @@ fn replay(window: Arc) -> Result<(), Box> { settings.translucent_batching, false, false, + false, + false, ))?; drop(pixels); render.pixels.pack(); diff --git a/crates/horizon-client-render/examples/standard_pack_gpu_entities.rs b/crates/horizon-client-render/examples/standard_pack_gpu_entities.rs index b967801987..41ef545b9e 100644 --- a/crates/horizon-client-render/examples/standard_pack_gpu_entities.rs +++ b/crates/horizon-client-render/examples/standard_pack_gpu_entities.rs @@ -126,6 +126,8 @@ fn fixture(window: Arc) -> Result<(), Box> { false, false, true, + false, + false, ))?; renderer.resize(320, 180); assert!( diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index 95d1f94b47..1e8e182436 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -261,6 +261,40 @@ pub struct Audio { /// Ticks since the output devices were last looked at (`DeviceTracker.tick`). #[cfg(feature = "audio")] device_ticks: u32, + /// The look at the output devices running on its own thread, until a tick + /// takes its answer: listing them can take 100 ms (#11490). + #[cfg(feature = "audio")] + device_probe: Option, +} +/// What one look at the output devices found: the system default, the +/// fingerprint, and the device list when those say it may have changed. +#[cfg(feature = "audio")] +struct DeviceProbe { + default: Option, + cards: Option, + devices: Option>, +} +#[cfg(feature = "audio")] +type PendingProbe = Arc>>; +/// Looks at the output devices as `should_change_device` did on the render +/// thread: the device list only when the default or the fingerprint moved. +#[cfg(feature = "audio")] +fn probe_devices(default_seen: Option, cards_seen: Option) -> DeviceProbe { + let default = default_output_name(); + let cards = card_fingerprint(); + // Listing the devices probes every one (on Linux every built-in PCM, + // printing ALSA's errors for the plugins this system lacks; on macOS + // every Core Audio device, about 90 ms with twenty of them; on Windows + // every endpoint, with no fingerprint to skip it); only the fingerprint's + // devices can change the list, and it is cheap. + let devices = (default != default_seen + || cards_changed(cards_seen.as_deref(), cards.as_deref())) + .then(output_devices); + DeviceProbe { + default, + cards, + devices, + } } fn decode(path: &Path) -> Option<(Vec, u32, u16)> { use symphonia::core::{ @@ -1051,6 +1085,8 @@ impl Audio { devices_seen: devices, #[cfg(feature = "audio")] device_ticks: 0, + #[cfg(feature = "audio")] + device_probe: None, } } #[cfg(not(feature = "audio"))] @@ -1211,6 +1247,8 @@ impl Audio { devices_seen: self.devices_seen.clone(), #[cfg(feature = "audio")] device_ticks: 0, + #[cfg(feature = "audio")] + device_probe: None, } } /// Sync the volume options, refreshing the categories that changed @@ -1232,7 +1270,8 @@ impl Audio { /// `SoundEngine.shouldChangeDevice`: the open device was lost, the system /// default changed (with no device chosen in the options), or the chosen /// device came back; the output is then opened again. The devices are looked - /// at once a second. + /// at once a second, on a thread of their own: a later tick reads what it + /// found. #[cfg(feature = "audio")] fn should_change_device(&mut self) -> bool { // A splitscreen share plays through the first player's output: it leaves the device, @@ -1246,29 +1285,54 @@ impl Audio { }; if lost { log::info!("Horizon: audio device was lost"); + self.device_probe = None; self.default_seen = default_output_name(); self.cards_seen = card_fingerprint(); self.devices_seen = output_devices(); return true; } + if let Some(pending) = &self.device_probe { + let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else { + return false; + }; + self.device_probe = None; + return self.devices_changed(probe, current); + } self.device_ticks += 1; if self._stream.is_none() || self.device_ticks < 20 { return false; } self.device_ticks = 0; - let default = default_output_name(); - let cards = card_fingerprint(); - // Listing the devices probes every one (on Linux every built-in PCM, - // printing ALSA's errors for the plugins this system lacks; on macOS - // every Core Audio device, about 90 ms with twenty of them); only the - // fingerprint's devices can change the list, and it is cheap. - if default == self.default_seen - && !cards_changed(self.cards_seen.as_deref(), cards.as_deref()) - { - return false; + let pending = PendingProbe::default(); + let out = Arc::clone(&pending); + let seen = (self.default_seen.clone(), self.cards_seen.clone()); + let started = std::thread::Builder::new() + .name("audio-devices".into()) + .spawn(move || { + let probe = probe_devices(seen.0, seen.1); + if let Ok(mut found) = out.lock() { + *found = Some(probe); + } + }); + match started { + Ok(_) => self.device_probe = Some(pending), + Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"), } + false + } + /// The rest of `shouldChangeDevice` with what a look at the devices found, + /// `current` being the device playing now. + #[cfg(feature = "audio")] + fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool { + let DeviceProbe { + default, + cards, + devices, + } = probe; + let Some(devices) = devices else { + return false; + }; self.cards_seen = cards; - let devices = output_devices(); // Only a change in the device lists is looked at, as in vanilla: a // preferred device that is listed but cannot open is not retried. if default == self.default_seen && devices == self.devices_seen { diff --git a/crates/horizon-client-render/src/block_entities.rs b/crates/horizon-client-render/src/block_entities.rs index 9547493583..8a09fe4f39 100644 --- a/crates/horizon-client-render/src/block_entities.rs +++ b/crates/horizon-client-render/src/block_entities.rs @@ -538,6 +538,11 @@ impl BlockEntities { }); Arc::clone(&self.tickers) } + /// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of + /// the last [`Self::tickers`]: what a diagnostics mod counts. + pub fn ticking_len(&self) -> usize { + self.live.len() + } /// Whether a slot from `tickers` still ticks. pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool { self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos) diff --git a/crates/horizon-client-render/src/breaking.rs b/crates/horizon-client-render/src/breaking.rs index 42fa5c3f94..ba056ab17b 100644 --- a/crates/horizon-client-render/src/breaking.rs +++ b/crates/horizon-client-render/src/breaking.rs @@ -114,12 +114,8 @@ mod tests { ambient_occlusion: true, particle: None, cube: None, - faces: vec![BakedFace { - layer: 0, - opaque: true, - translucent: false, - fusion: None, - quad: ModelQuad { + faces: vec![BakedFace::new( + ModelQuad { positions: [ [0.0, 0.5, 0.0], [0.0, 0.5, 1.0], @@ -142,7 +138,11 @@ mod tests { render_type: None, shade: true, }, - }], + 0, + true, + false, + None, + )], } } #[test] diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index efe8b7ada0..bdd2c837f3 100644 --- a/crates/horizon-client-render/src/brewing.rs +++ b/crates/horizon-client-render/src/brewing.rs @@ -387,8 +387,21 @@ impl BrewingStand { out.brewed = true; } } -/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier). +/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier), +/// respecting component patches before falling back to the item's defaults. pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> { + if let Some(data) = stack.data() { + if data.other("!minecraft:brewing_fuel").is_some() { + return None; + } + if let Some(own) = data.other("minecraft:brewing_fuel") { + let component = horizon_client_core::scoreboard::tag_to_json(own); + return Some(( + component["uses"].clone(), + component["speed_multiplier"].clone(), + )); + } + } items.get(&stack.item).and_then(|i| i.brewing_fuel.clone()) } @@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> mod tests { use super::*; + #[test] + fn stack_fuel_controls_brew_duration_and_consumption() { + let items = Items::for_tests(Default::default()); + let brewing = Brewing { + reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + inputs: HashSet::new(), + recipes: vec![BrewingRecipe { + id: "awkward".into(), + input: PotionIngredient { + items: ["minecraft:potion".to_owned()].into_iter().collect(), + potions: Some(["minecraft:water".to_owned()].into_iter().collect()), + }, + reagent: PotionIngredient { + items: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + potions: None, + }, + output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"), + }], + }; + let mut fuel = ItemStack::new("minecraft:stone", 2); + fuel.data_mut().set_loot_component( + "minecraft:brewing_fuel", + &serde_json::json!({"uses": 7, "speed_multiplier": 2.5}), + ); + let mut stand = BrewingStand::new(); + stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")); + stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1)); + stand.items[4] = Some(fuel); + let providers = Providers::empty(); + stand.tick(&brewing, &items, &providers); + assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7)); + assert_eq!(stand.items[4].as_ref().unwrap().count, 1); + for _ in 1..160 { + assert!(!stand.tick(&brewing, &items, &providers).brewed); + } + assert!(stand.tick(&brewing, &items, &providers).brewed); + assert_eq!( + stand.items[0].as_ref().unwrap().potion(), + Some("minecraft:awkward") + ); + assert_eq!(stand.fuel, 6); + assert_eq!(stand.items[4].as_ref().unwrap().count, 1); + } + #[test] fn potion_colours_and_names() { // Swiftness: speed colour 3402751 = 0x33EBFF. diff --git a/crates/horizon-client-render/src/chat.rs b/crates/horizon-client-render/src/chat.rs index a8fffa4e05..c40ff82b7d 100644 --- a/crates/horizon-client-render/src/chat.rs +++ b/crates/horizon-client-render/src/chat.rs @@ -1188,11 +1188,27 @@ impl SuggestLayout { }; } +/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`). +#[derive(Debug, PartialEq, Eq)] +pub struct SuggestionCompletion { + /// The suggestion list's original input, also when cycling with Tab. + pub original: String, + /// The replaced range in `original`, as Java UTF-16 start/end offsets. + pub range: (usize, usize), + pub text: String, + /// The resulting input, after the text field's length limit. + pub input: String, +} + /// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and /// `CommandSuggestions` (argument colouring, usage lines, parse errors, /// the suggestion list, Tab completion, history). pub struct ChatScreen { pub input: TextField, + /// `ChatScreen.isDraft`: the box opened with the draft saved last time and + /// has not been edited since. A draft draws grey italic, and the first + /// Backspace clears the whole box instead of one character. + is_draft: bool, width: i32, height: i32, history_buffer: String, @@ -1207,6 +1223,7 @@ pub struct ChatScreen { /// the command up to the cursor. request: Option<(i32, String)>, suggestions: Option, + completion: Option, usage: Vec, usage_position: i32, usage_width: i32, @@ -1300,11 +1317,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String { s.to_owned() } impl ChatScreen { - /// `ChatScreen.init`: the input at the bottom, focused, holding `initial`. + /// `ChatScreen.init`: the input at the bottom, focused, holding `initial`, + /// which is a draft when `is_draft` (`ChatComponent.createScreen`). pub fn new( m: &Measure, c: &Commands, initial: &str, + is_draft: bool, history_len: usize, width: i32, height: i32, @@ -1317,6 +1336,7 @@ impl ChatScreen { input.set_value(m, initial); let mut s = Self { input, + is_draft, width, height, history_buffer: String::new(), @@ -1326,6 +1346,7 @@ impl ChatScreen { awaiting: None, request: None, suggestions: None, + completion: None, usage: Vec::new(), usage_position: 0, usage_width: 0, @@ -1353,6 +1374,7 @@ impl ChatScreen { input.set_value(m, command); let mut s = Self { input, + is_draft: false, width, height, history_buffer: String::new(), @@ -1362,6 +1384,7 @@ impl ChatScreen { awaiting: None, request: None, suggestions: None, + completion: None, usage: Vec::new(), usage_position: 0, usage_width: 0, @@ -1377,12 +1400,18 @@ impl ChatScreen { pub fn value(&self) -> &str { &self.input.value } + /// Take the most recently accepted suggestion, at most once. Call after + /// each key or click to observe every acceptance, including Tab cycling. + pub fn take_suggestion_completion(&mut self) -> Option { + self.completion.take() + } /// Replace the input for a suggest-command link and refresh completions. pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) { self.input.set_value(m, command); self.edited(m, c); } - /// `ChatScreen.resize`: a new screen with the current text. + /// `ChatScreen.resize`: a new screen with the current text, still a draft + /// while it has not been edited (`ChatScreen.resize` keeps the field). pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) { if (width, height) != (self.width, self.height) { let value = self.input.value.clone(); @@ -1390,7 +1419,16 @@ impl ChatScreen { let abilities = self.abilities; let message = std::mem::take(&mut self.input.message); *self = if self.layout == SuggestLayout::CHAT { - Self::new(m, c, &value, self.history_pos, width, height, abilities) + Self::new( + m, + c, + &value, + self.is_draft, + self.history_pos, + width, + height, + abilities, + ) } else { Self::command_block(m, c, &value, width, height) }; @@ -1402,6 +1440,7 @@ impl ChatScreen { fn edited(&mut self, m: &Measure, c: &Commands) { self.allow_suggestions = true; self.update_command_info(m, c); + self.is_draft = false; } /// Ctrl+A/C/V/X in the input box. pub fn shortcut( @@ -1415,6 +1454,24 @@ impl ChatScreen { self.edited(m, c); } } + /// Edit the existing input and refresh its command responder when it changes. + pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) { + let before = ( + self.input.value.clone(), + self.input.cursor, + self.input.highlight, + ); + f(&mut self.input); + if before + != ( + self.input.value.clone(), + self.input.cursor, + self.input.highlight, + ) + { + self.edited(m, c); + } + } /// Typed text (`charTyped` per character). pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) { for ch in text.chars() { @@ -1714,6 +1771,17 @@ impl ChatScreen { self.select(l, l.current as isize); self.keep_suggestions = false; l.tab_cycles = true; + let units = |byte: usize| { + l.original[..byte.min(l.original.len())] + .encode_utf16() + .count() + }; + self.completion = Some(SuggestionCompletion { + original: l.original.clone(), + range: (units(s.range.0), units(s.range.1)), + text: s.text, + input: self.input.value.clone(), + }); } /// `ChatScreen.keyPressed`. pub fn key( @@ -1729,6 +1797,14 @@ impl ChatScreen { if self.suggestions_key(m, c, key, shift) { return ChatAction::None; } + // ChatScreen.keyPressed: while the box holds an unedited draft, the + // first Backspace clears all of it rather than one character. + if self.is_draft && key == K::Backspace { + self.input.set_value(m, ""); + self.is_draft = false; + self.edited(m, c); + return ChatAction::None; + } match key { K::Escape => return ChatAction::Close, K::Enter | K::NumpadEnter => { @@ -1862,8 +1938,13 @@ impl ChatScreen { l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize; true } - /// `CommandSuggestions.formatText`: argument colours over the input. + /// `CommandSuggestions.formatText`: argument colours over the input. The + /// draft formatter (`ChatScreen.formatChat`) is registered before this one + /// and wins while the draft is unedited: the whole text is grey italic. fn format(&self, value: &str, offset: usize) -> Text { + if self.is_draft { + return Text::literal(value).with_color(text::GRAY).italic(); + } let gray = Style::color(text::GRAY); let Some(parse) = &self.parse else { return Text::literal(value); diff --git a/crates/horizon-client-render/src/chat_tests.rs b/crates/horizon-client-render/src/chat_tests.rs index 05381ae4d5..b01cfc0d1b 100644 --- a/crates/horizon-client-render/src/chat_tests.rs +++ b/crates/horizon-client-render/src/chat_tests.rs @@ -7,6 +7,85 @@ fn full() -> ChatAbilities { ChatAbilities::new(ChatVisibility::Full) } +#[test] +fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() { + use winit::keyboard::KeyCode as K; + let font = font(); + let m = Measure(&font); + let registries = Registries { + players: vec!["Steve".into(), "Steven".into()], + ..Registries::default() + }; + let c = Commands { + dispatcher: crate::command::vanilla::game_dispatcher(), + registries: ®istries, + }; + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); + screen.type_text(&m, &c, "😀 hi st"); + assert!(screen.take_suggestion_completion().is_none()); + screen.key(&m, &c, &[], K::Tab, false, false); + assert!( + screen.take_suggestion_completion().is_none(), + "opening is not accepting" + ); + for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] { + screen.key(&m, &c, &[], K::Tab, shift, false); + assert_eq!(screen.value(), format!("😀 hi {text}")); + assert_eq!( + screen.take_suggestion_completion(), + Some(SuggestionCompletion { + original: "😀 hi st".into(), + range: (6, 8), + text: text.into(), + input: format!("😀 hi {text}"), + }) + ); + assert!( + screen.take_suggestion_completion().is_none(), + "reported once" + ); + } + screen.key(&m, &c, &[], K::ArrowDown, false, false); + assert!( + screen.take_suggestion_completion().is_none(), + "selection is not accepting" + ); + screen.key(&m, &c, &[], K::Escape, false, false); + assert!(screen.take_suggestion_completion().is_none()); +} + +#[test] +fn clicked_suggestion_reports_the_actual_length_limited_input() { + use winit::keyboard::KeyCode as K; + let font = font(); + let m = Measure(&font); + let registries = Registries { + players: vec!["Steve".into()], + ..Registries::default() + }; + let c = Commands { + dispatcher: crate::command::vanilla::game_dispatcher(), + registries: ®istries, + }; + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); + screen.input.max_length = 6; + screen.type_text(&m, &c, "hi st"); + screen.key(&m, &c, &[], K::Tab, false, false); + let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect; + assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1))); + assert_eq!(screen.value(), "hi Ste"); + assert_eq!( + screen.take_suggestion_completion(), + Some(SuggestionCompletion { + original: "hi st".into(), + range: (3, 5), + text: "Steve".into(), + input: "hi Ste".into(), + }) + ); + assert!(screen.take_suggestion_completion().is_none()); +} + fn font() -> Font { // The missing-glyph metrics let these tests run without game assets. Font::load( @@ -201,7 +280,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() { assert_eq!(link.component()["click_event"]["command"], expected); let mut chat = Chat::default(); add(&mut chat, &font, Text::literal("Found at ").append(link)); - let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full()); + let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full()); let x = 4.0 + f64::from(font.width("Found at ")); assert!(!screen.click(&m, &commands, x, 315.0)); let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else { @@ -242,6 +321,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() { &m, &commands, "unfinished draft", + false, history.len(), 640, 360, @@ -263,6 +343,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() { } } +/// `ChatScreen.isDraft`: the box opens on the draft kept from last time +/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init` +/// stores), so the text draws grey italic through `ChatScreen.formatChat` +/// and the first Backspace clears the whole draft instead of one character. +/// Editing the draft (`onEdited`) ends both. +#[test] +fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() { + use winit::keyboard::KeyCode::Backspace; + + let font = font(); + let m = Measure(&font); + let registries = Registries::default(); + let c = Commands { + dispatcher: crate::command::vanilla::game_dispatcher(), + registries: ®istries, + }; + // The input's own text as the game draws it: x 4, y height - 12. + let input_text = |screen: &mut ChatScreen| -> Text { + screen + .draw(&m, (0, 0), 0, i32::MIN) + .into_iter() + .find_map(|draw| match draw { + Draw::Text { + text, x: 4, y: 348, .. + } => Some(text), + _ => None, + }) + .expect("the input draws its text") + }; + let draft_style = |t: &Text| { + t.spans + .iter() + .all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic()) + }; + let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full()); + assert_eq!(screen.value(), "half-written"); + assert!( + draft_style(&input_text(&mut screen)), + "the restored draft draws grey italic" + ); + // Typing into the draft (`onEdited`) ends the draft style, and Backspace + // eats one character again. + screen.type_text(&m, &c, "!"); + assert_eq!(screen.value(), "half-written!"); + let typed = input_text(&mut screen); + assert!( + !typed.spans.iter().any(|s| s.style.is_italic()), + "an edited draft is not grey italic" + ); + assert_eq!( + screen.key(&m, &c, &[], Backspace, false, false), + ChatAction::None + ); + assert_eq!(screen.value(), "half-written"); + // A draft left alone clears whole on its first Backspace. + let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full()); + assert_eq!( + screen.key(&m, &c, &[], Backspace, false, false), + ChatAction::None + ); + assert_eq!(screen.value(), "", "the first Backspace clears the draft"); + // The flag is gone with it: typing is ordinary text, and Backspace eats + // one character. + screen.type_text(&m, &c, "hi"); + assert_eq!(screen.value(), "hi"); + let typed = input_text(&mut screen); + assert!(!typed.spans.iter().any(|s| s.style.is_italic())); + assert_eq!( + screen.key(&m, &c, &[], Backspace, false, false), + ChatAction::None + ); + assert_eq!(screen.value(), "h"); + // A box opened on ordinary text deletes one character at a time. + let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full()); + assert!(!draft_style(&input_text(&mut screen))); + assert_eq!( + screen.key(&m, &c, &[], Backspace, false, false), + ChatAction::None + ); + assert_eq!(screen.value(), "half-writte"); + // A command draft is grey italic as a whole: `formatChat` is registered + // before `CommandSuggestions.formatText`, so it wins over the argument + // colours. + let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full()); + assert!(draft_style(&input_text(&mut screen))); +} + #[test] fn mod_commands_are_coloured_and_complete_like_vanilla_ones() { let font = font(); @@ -283,7 +450,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() { dispatcher, registries: ®istries, }; - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); screen.type_text(&m, &c, line); screen }; @@ -333,7 +500,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() { dispatcher, registries, }; - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); screen.type_text(&m, &c, line); // Chat shows no list by itself: Tab shows it, Tab again uses it. for _ in 0..2 { @@ -381,7 +548,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() { let tab = |screen: &mut ChatScreen| { screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false); }; - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); screen.type_text(&m, &c, "/bossbar get pt"); let (request, sent) = screen.take_request().expect("the server is asked"); assert_eq!(sent, "/bossbar get pt"); @@ -395,15 +562,25 @@ fn ask_server_nodes_wait_for_the_servers_completions() { tab(&mut screen); assert_eq!(screen.value(), "/bossbar get pt"); screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check")); + assert!(screen.take_suggestion_completion().is_none()); tab(&mut screen); assert_eq!(screen.value(), "/bossbar get pt6:check"); + assert_eq!( + screen.take_suggestion_completion(), + Some(SuggestionCompletion { + original: "/bossbar get pt".into(), + range: (13, 15), + text: "pt6:check".into(), + input: "/bossbar get pt6:check".into(), + }) + ); // Without the server's provider the local list is done at once. let local = crate::command::vanilla::game_dispatcher(); let c = Commands { dispatcher: local, registries: ®istries, }; - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full()); screen.type_text(&m, &c, "/bossbar get pt"); assert_eq!(screen.take_request(), None); } @@ -473,7 +650,7 @@ fn the_chat_option_decides_what_may_be_sent() { assert_eq!(abilities.allows("hi"), chat, "{visibility:?}"); assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}"); for (line, allowed) in [("hi", chat), ("/time set day", command)] { - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities); + let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities); screen.type_text(&m, &c, line); match enter(&mut screen) { ChatAction::Submit(sent) => { @@ -499,7 +676,16 @@ fn restricted_input_says_why_it_will_not_send() { registries: ®istries, }; let usage = |visibility, line: &str| { - let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility)); + let mut screen = ChatScreen::new( + &m, + &c, + "", + false, + 0, + 640, + 360, + ChatAbilities::new(visibility), + ); screen.type_text(&m, &c, line); screen.usage.iter().map(|t| t.plain()).collect::>() }; diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index f42373e0e5..6703b65530 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -11,7 +11,7 @@ use horizon_world::{ use std::{ collections::HashMap, sync::{ - Arc, + Arc, Mutex, OnceLock, PoisonError, atomic::{AtomicU64, Ordering}, }, }; @@ -108,6 +108,9 @@ pub enum BlockEntity { pub struct ModBlockEntity { fields: ModBlockEntityFields, revision: ModRevision, + // A client update can consume transient effects without changing saved + // fields. Most entities never use this; do not allocate their queue. + client_update_fields: OnceLock>, } /// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved /// compound without `id`, `x`, `y`, `z` and `Items`, its item slots @@ -138,11 +141,55 @@ impl ModBlockEntity { born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed), changes: 0, }, + client_update_fields: OnceLock::new(), } } pub fn revision(&self) -> ModRevision { self.revision } + /// Replace the owner's pending, one-shot client update fields. These + /// fields are not saved, cloned, compared or retained for drawing, and + /// changing them does not change this entity's revision. The owner must + /// explicitly take and merge them into its update tag. Calling this does + /// not schedule a block update. + /// + /// An empty batch clears a pending batch without allocating an idle queue. + pub fn set_client_update_fields(&mut self, fields: Compound) { + if fields.is_empty() { + self.clear_client_update_fields(); + return; + } + *self + .client_update_fields + .get_or_init(|| Mutex::new(Compound::new())) + .lock() + .unwrap_or_else(PoisonError::into_inner) = fields; + } + /// Discard the owner's pending client update fields, without changing + /// saved data, revision or a queue that has never been used. + pub fn clear_client_update_fields(&mut self) { + if let Some(fields) = self.client_update_fields.get() { + fields + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + } + } + /// Atomically take the owner's pending fields at update-tag construction. + /// A loaded chunk and a block change use this same boundary. Construct a + /// tag once, then send that tag to every recipient; another construction + /// sees an empty batch. A save or a clone does not consume this batch. + /// + /// This is safe for an update-tag hook receiving only a shared reference, + /// including a saved chunk encoded by a worker thread. If a previous lock + /// holder panicked, preserve its pending data instead of dropping it. + pub fn take_client_update_fields(&self) -> Compound { + self.client_update_fields + .get() + .map_or_else(Compound::new, |fields| { + std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner)) + }) + } /// A client's copy loads the tag the server sent (`loadWithComponents` /// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields /// replace its own of the same names. One it already has as sent is @@ -706,6 +753,85 @@ pub struct CommandBlock { mod tests { use super::*; + #[test] + fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() { + let mut machine = ModBlockEntity::new(ModBlockEntityFields { + id: "test:machine".into(), + nbt: Compound::from([("progress".into(), Tag::Int(7))]), + ..Default::default() + }); + let revision = machine.revision(); + let saved = machine.fields.clone(); + let debug = format!("{machine:?}"); + machine.clear_client_update_fields(); + machine.set_client_update_fields(Compound::new()); + assert!(machine.take_client_update_fields().is_empty()); + assert!(machine.client_update_fields.get().is_none()); + + machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))])); + let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]); + machine.set_client_update_fields(expected.clone()); + assert_eq!(machine.take_client_update_fields(), expected); + assert!(machine.take_client_update_fields().is_empty()); + + machine.set_client_update_fields(expected.clone()); + let copy = machine.clone(); + assert_eq!(machine, copy, "transient fields are not retained contents"); + assert_ne!(machine.revision(), copy.revision()); + assert!(copy.client_update_fields.get().is_none()); + assert!(copy.take_client_update_fields().is_empty()); + assert_eq!(machine.take_client_update_fields(), expected); + machine.set_client_update_fields(expected); + machine.clear_client_update_fields(); + assert!(machine.take_client_update_fields().is_empty()); + assert_eq!(machine.fields, saved); + assert_eq!(machine.revision(), revision); + assert_eq!(format!("{machine:?}"), debug); + } + + #[test] + fn concurrent_client_update_constructions_consume_one_batch() { + let mut machine = ModBlockEntity::default(); + let expected = Compound::from([("effects".into(), Tag::Int(23))]); + machine.set_client_update_fields(expected.clone()); + let received = std::thread::scope(|scope| { + let handles = (0..16) + .map(|_| scope.spawn(|| machine.take_client_update_fields())) + .collect::>(); + handles + .into_iter() + .map(|h| h.join().unwrap()) + .collect::>() + }); + let nonempty = received + .into_iter() + .filter(|c| !c.is_empty()) + .collect::>(); + assert_eq!(nonempty, [expected]); + } + + #[test] + fn a_poisoned_client_update_lock_preserves_its_pending_batch() { + let mut machine = ModBlockEntity::default(); + let expected = Compound::from([("effects".into(), Tag::Int(23))]); + machine.set_client_update_fields(expected.clone()); + std::thread::scope(|scope| { + assert!( + scope + .spawn(|| { + let _held = machine.client_update_fields.get().unwrap().lock().unwrap(); + panic!("interrupt before consuming the queued batch"); + }) + .join() + .is_err() + ); + }); + assert_eq!(machine.take_client_update_fields(), expected); + assert!(machine.take_client_update_fields().is_empty()); + machine.set_client_update_fields(expected.clone()); + assert_eq!(machine.take_client_update_fields(), expected); + } + #[test] fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() { let mut machine = ModBlockEntity::new(ModBlockEntityFields { diff --git a/crates/horizon-client-render/src/collisions.rs b/crates/horizon-client-render/src/collisions.rs index 0da8f04540..40124594e8 100644 --- a/crates/horizon-client-render/src/collisions.rs +++ b/crates/horizon-client-render/src/collisions.rs @@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> { max_step: f32, on_ground: bool, border: Option<(&BorderGeometry, [f64; 2])>, + ) -> Result { + self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[]) + } + /// [`Self::resolve_in_border`], also stopped by `entities`: the boxes + /// `EntityGetter.getEntityCollisions` gave the moving entity. + pub fn resolve_in_border_with( + self, + bounds: Aabb, + movement: Vec3, + max_step: f32, + on_ground: bool, + border: Option<(&BorderGeometry, [f64; 2])>, + entities: &[Aabb], ) -> Result { let context = self.context(bounds); EntityCollisions { blocks: self, - entities: &[], + entities, border, } .resolve_with_context(bounds, movement, max_step, on_ground, context) diff --git a/crates/horizon-client-render/src/crash.rs b/crates/horizon-client-render/src/crash.rs index 123704d6bc..502f064ba5 100644 --- a/crates/horizon-client-render/src/crash.rs +++ b/crates/horizon-client-render/src/crash.rs @@ -10,6 +10,7 @@ use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use chrono::{DateTime, Local}; +use horizon_telemetry::system::machine_arch; /// What the game is doing, set as it goes: graphics adapter, settings, packs, world. static DETAILS: Mutex> = Mutex::new(Vec::new()); @@ -242,9 +243,18 @@ fn read(path: &str) -> Option { std::fs::read_to_string(path).ok() } +/// The build's architecture, and the machine's after "on" when it emulates the build. +fn arch_label(build: &str, machine: &str) -> String { + if build == machine { + build.to_owned() + } else { + format!("{build} on {machine}") + } +} + /// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string. fn operating_system() -> String { - let arch = std::env::consts::ARCH; + let arch = arch_label(std::env::consts::ARCH, machine_arch()); let version = if cfg!(target_os = "linux") { let name = read("/etc/os-release").and_then(|t| { t.lines() @@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option { mod tests { use super::*; + #[test] + fn an_emulated_build_names_both_architectures() { + // A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64"). + assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64"); + assert_eq!(arch_label("aarch64", "aarch64"), "aarch64"); + assert_eq!(arch_label("x86_64", "x86_64"), "x86_64"); + // On the machine running the tests: native builds name one architecture. + let label = arch_label(std::env::consts::ARCH, machine_arch()); + assert!(label.starts_with(std::env::consts::ARCH)); + } + #[test] fn a_release_reports_its_built_in_version() { // release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed. diff --git a/crates/horizon-client-render/src/custom_sky.rs b/crates/horizon-client-render/src/custom_sky.rs new file mode 100644 index 0000000000..b85cf8ee50 --- /dev/null +++ b/crates/horizon-client-render/src/custom_sky.rs @@ -0,0 +1,243 @@ +//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with +//! their decoded textures, read once with the pack, and each frame's choice of layers, their +//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the +//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays +//! vanilla. + +use std::path::Path; + +use glam::{Mat4, Vec3}; +use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition}; + +/// A layer's texture, RGBA8. +pub struct Image { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer +/// over all worlds, in `worlds` order). +#[derive(Default)] +pub struct CustomSky { + worlds: Vec<(i32, Vec)>, + pub images: Vec, +} + +/// One layer of a frame: which texture, with which blend, how bright and turned how. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Draw { + pub image: u16, + pub blend: Blend, + pub brightness: f32, + /// Camera-relative world from the skybox's space. + pub rotation: Mat4, +} + +/// The eased brightness of each layer, kept by the game between frames. +#[derive(Default)] +pub struct State { + world: Option, + eased: Vec, +} + +/// Textures past this many texels in all are left out (a skybox is a few megapixels at most). +const TEXEL_BUDGET: u64 = 64 << 20; + +impl CustomSky { + /// The custom skies of the overlay at `root`. A layer whose texture does not decode is + /// dropped with a warning. + pub fn load(root: &Path) -> Self { + let skies = Skies::scan(root); + let mut out = Self::default(); + let mut texels = 0u64; + for (world, sky) in skies.worlds { + let mut layers = Vec::new(); + for layer in sky.layers { + match crate::hud::decode(&root.join(&layer.source)) { + Ok((width, height, rgba)) + if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET => + { + texels += u64::from(width) * u64::from(height); + out.images.push(Image { + width, + height, + rgba, + }); + layers.push(layer); + } + Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source), + Err(e) => log::warn!("{}: {e:#}", layer.source), + } + } + if !layers.is_empty() { + out.worlds.push((world, layers)); + } + } + out + } + + pub fn is_empty(&self) -> bool { + self.worlds.is_empty() + } + + /// Whether `world` has layers (`0` the Overworld, `1` the End). + pub fn has(&self, world: i32) -> bool { + self.worlds.iter().any(|(w, _)| *w == world) + } + + /// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose + /// conditions hold, eased over their `transition` by `seconds` since the last frame, at the + /// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon). + pub fn draws( + &self, + world: i32, + conditions: &Conditions, + sun_turns: f32, + seconds: f32, + state: &mut State, + out: &mut Vec, + ) { + out.clear(); + let mut first = 0; + let mut found = None; + for (w, layers) in &self.worlds { + if *w == world { + found = Some(layers); + break; + } + first += layers.len(); + } + let Some(layers) = found else { + state.world = None; + return; + }; + // A new world (or pack) starts each layer where its conditions put it: no fade in. + let snap = state.world != Some(world) || state.eased.len() != layers.len(); + if snap { + state.world = Some(world); + state.eased.clear(); + state.eased.resize(layers.len(), Transition::default()); + } + for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() { + let target = if layer.shows(conditions) { 1.0 } else { 0.0 }; + let shown = if snap { + eased.value = target; + target + } else { + eased.step(target, seconds, layer.transition) + }; + let brightness = shown * layer.brightness(conditions.day_time); + if brightness <= 0.0 { + continue; + } + let turns = layer.turns(conditions.day_time, sun_turns); + let rotation = if turns == 0.0 { + Mat4::IDENTITY + } else { + Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU) + }; + out.push(Draw { + image: (first + i) as u16, + blend: layer.blend, + brightness, + rotation, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_optifine::sky::Now; + + fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) { + let root = std::env::temp_dir().join(format!( + "horizon-custom-sky-{}-{}", + std::process::id(), + files.len() + )); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("assets/minecraft/optifine/sky/world0"); + std::fs::create_dir_all(&dir).unwrap(); + let png = { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, 6, 4); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255].repeat(24)) + .unwrap(); + writer.finish().unwrap(); + bytes + }; + for (name, text) in files { + std::fs::write(dir.join(format!("{name}.properties")), text).unwrap(); + std::fs::write(dir.join(format!("{name}.png")), &png).unwrap(); + } + let sky = CustomSky::load(&root); + (root, sky) + } + + fn noon(biome: &str) -> Conditions<'_> { + Conditions { + day_time: 6000, + weather: Now::Clear, + biome, + y: 64, + } + } + + #[test] + fn layers_ease_in_at_a_biome_border_but_not_on_arrival() { + let (root, sky) = pack(&[ + ("sky1", "biomes=desert\ntransition=2"), + ("sky2", "blend=alpha\nrotate=false"), + ]); + let _ = std::fs::remove_dir_all(root); + assert!(sky.has(0) && !sky.has(1)); + assert_eq!(sky.images[0].width, 6); + let (mut state, mut out) = (State::default(), Vec::new()); + sky.draws( + 0, + &noon("minecraft:desert"), + 0.0, + 0.05, + &mut state, + &mut out, + ); + assert_eq!( + out.iter().map(|d| d.brightness).collect::>(), + [1.0, 1.0] + ); + // Out of the desert: the first layer fades over its two seconds. + sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out); + assert_eq!(out[0].brightness, 0.75); + assert_eq!(out[1].blend, Blend::Alpha); + sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out); + assert_eq!(out.len(), 1, "a dark layer is not drawn"); + assert_eq!(out[0].image, 1); + // Into the End, which has no layers, and back: no fade. + sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out); + assert!(out.is_empty()); + sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out); + assert_eq!(out.len(), 2); + } + + #[test] + fn a_layer_turns_west_like_the_sun() { + let (root, sky) = pack(&[("sky1", "")]); + let _ = std::fs::remove_dir_all(root); + let (mut state, mut out) = (State::default(), Vec::new()); + // A quarter turn after noon (sunset): the skybox's zenith has gone down to the west. + let c = Conditions { + day_time: 12000, + ..noon("minecraft:plains") + }; + sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out); + let up = out[0].rotation.transform_vector3(Vec3::Y); + assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}"); + } +} diff --git a/crates/horizon-client-render/src/effects.rs b/crates/horizon-client-render/src/effects.rs index fa1d2a8460..b1e74e4ad0 100644 --- a/crates/horizon-client-render/src/effects.rs +++ b/crates/horizon-client-render/src/effects.rs @@ -14,6 +14,8 @@ pub struct EffectInstance { pub amplifier: i32, pub ambient: bool, pub visible: bool, + /// `MobEffectInstance.showIcon`: whether the HUD and inventory list it. + pub show_icon: bool, pub hidden: Option>, } impl EffectInstance { @@ -24,9 +26,18 @@ impl EffectInstance { amplifier: amplifier.clamp(0, 255), ambient: false, visible: true, + show_icon: true, hidden: None, } } + /// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`: + /// the icon shows exactly when the particles do. + pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self { + self.ambient = ambient; + self.visible = visible; + self.show_icon = visible; + self + } fn infinite(&self) -> bool { self.duration == -1 } @@ -71,6 +82,10 @@ impl EffectInstance { self.visible = take.visible; changed = true; } + if take.show_icon != self.show_icon { + self.show_icon = take.show_icon; + changed = true; + } changed } /// `MobEffectInstance.tickClient`: a client counts its effects down @@ -98,6 +113,7 @@ impl EffectInstance { self.amplifier = h.amplifier; self.ambient = h.ambient; self.visible = h.visible; + self.show_icon = h.show_icon; self.hidden = h.hidden; } } @@ -116,8 +132,9 @@ pub enum EffectAction { } /// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects. fn periodic(id: &str, tick: i32, amp: i32) -> Option { + // Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`). let every = |base: i32| { - let interval = base >> amp; + let interval = base.wrapping_shr(amp as u32); interval <= 0 || tick % interval == 0 }; match id { @@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option { "wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)), "hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)), "saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)), - "instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)), + "instant_health" if tick >= 1 => Some(EffectAction::Heal( + 4i32.wrapping_shl(amp as u32).max(0) as f32, + )), "instant_damage" if tick >= 1 => Some(EffectAction::Hurt( "minecraft:magic", - (6 << amp.min(30)) as f32, + 6i32.wrapping_shl(amp as u32) as f32, )), _ => None, } @@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option { pub fn instant(id: &str, amp: i32, scale: f64) -> Option { match id { "instant_health" => Some(EffectAction::Heal( - (scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32, + (scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32, )), "instant_damage" => Some(EffectAction::Hurt( "minecraft:magic", - (scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32, + (scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32, )), "saturation" => Some(EffectAction::Eat(amp + 1, 1.0)), _ => None, @@ -168,6 +187,57 @@ pub enum OmenTick { /// It added this effect, then returned false. Add(EffectInstance), } +/// A saved player's effect: `MobEffectInstance.Details` under its `id`. +#[derive(Clone, Debug, PartialEq)] +pub struct SavedEffect { + pub id: String, + pub duration: i32, + pub amplifier: i32, + pub ambient: bool, + /// `show_particles`. + pub particles: bool, + /// `show_icon`. + pub icon: bool, + /// `hidden_effect`: the weaker, longer instance of the same effect that + /// comes back when this one ends. + pub hidden: Option>, +} +impl SavedEffect { + /// Ambient off, particles and icon shown, nothing hidden: the codec's + /// defaults. + pub fn new(id: impl Into, duration: i32, amplifier: i32) -> Self { + Self { + id: id.into(), + duration, + amplifier, + ambient: false, + particles: true, + icon: true, + hidden: None, + } + } + fn of(e: &EffectInstance) -> Self { + Self { + id: potions::namespaced(e.id).to_owned(), + duration: e.duration, + amplifier: e.amplifier, + ambient: e.ambient, + particles: e.visible, + icon: e.show_icon, + hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))), + } + } + /// `new MobEffectInstance(effect, details)`: the hidden chain comes back + /// with it. + fn instance(&self, id: &'static str) -> EffectInstance { + let mut e = EffectInstance::new(id, self.duration, self.amplifier); + e.ambient = self.ambient; + e.visible = self.particles; + e.show_icon = self.icon; + e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id))); + e + } +} #[derive(Clone, Debug, Default)] pub struct PlayerEffects { pub list: Vec, @@ -200,39 +270,25 @@ impl PlayerEffects { pub fn clear(&mut self) { self.list.clear(); } - /// The effects of a saved player (`Player.active_effects`: id, - /// duration, amplifier, ambient, particles visible). - pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self { + /// The effects of a saved player (`Player.active_effects`), each with + /// the instances it hides. + pub fn from_saved(saved: &[SavedEffect]) -> Self { let mut effects = Self::default(); - for (id, duration, amplifier, ambient, particles) in saved { - if let Some(def) = potions::effect(id) { - let mut e = EffectInstance::new(def.id, *duration, *amplifier); - e.ambient = *ambient; - e.visible = *particles; - effects.add(e); + for s in saved { + if let Some(def) = potions::effect(&s.id) { + effects.add(s.instance(def.id)); } } effects } /// `Player.active_effects` for the save. - pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> { - self.list - .iter() - .map(|e| { - ( - potions::namespaced(e.id).to_owned(), - e.duration, - e.amplifier, - e.ambient, - e.visible, - ) - }) - .collect() + pub fn saved(&self) -> Vec { + self.list.iter().map(SavedEffect::of).collect() } /// ClientboundUpdateMobEffectPacket's flags without blending: ambient, - /// particles visible and the icon shown (always, for these effects). + /// particles visible and the icon shown. pub fn flags(e: &EffectInstance) -> i8 { - i8::from(e.ambient) | i8::from(e.visible) << 1 | 4 + i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2 } /// `LivingEntity.tickEffects` (server part) with the omens' effect ticks: /// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect` @@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64, #[cfg(test)] mod tests { use super::*; + /// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo + /// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is + /// at least 0 (#11386). + #[test] + fn heal_and_harm_shift_like_java() { + let magic = "minecraft:magic"; + assert_eq!( + instant("instant_health", 32, 1.0), + Some(EffectAction::Heal(4.0)) + ); + assert_eq!( + instant("instant_damage", 33, 1.0), + Some(EffectAction::Hurt(magic, 12.0)) + ); + assert_eq!( + instant("instant_health", 1, 0.5), + Some(EffectAction::Heal(4.0)) + ); + assert_eq!( + periodic("instant_health", 1, 32), + Some(EffectAction::Heal(4.0)) + ); + // 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN. + assert_eq!( + periodic("instant_health", 1, 29), + Some(EffectAction::Heal(0.0)) + ); + assert_eq!( + periodic("instant_damage", 1, 30), + Some(EffectAction::Hurt(magic, i32::MIN as f32)) + ); + // Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick). + assert_eq!(periodic("regeneration", 49, 32), None); + assert_eq!( + periodic("regeneration", 50, 32), + Some(EffectAction::Heal(1.0)) + ); + } + /// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false, + /// false)`) shows neither particles nor its icon; a renewal that shows the + /// icon turns it back on (`MobEffectInstance.update`). + #[test] + fn an_effect_without_particles_hides_its_icon() { + let mut fx = PlayerEffects::default(); + fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false)); + let darkness = fx.get("darkness").unwrap(); + // ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit. + assert_eq!(PlayerEffects::flags(darkness), 0); + assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon); + assert!(fx.add(EffectInstance::new("darkness", 100, 0))); + assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4); + + // Details keeps show_icon apart from show_particles. + let mut speed = EffectInstance::new("speed", 100, 0); + speed.show_icon = false; + let mut fx = PlayerEffects::default(); + fx.add(speed); + let saved = fx.saved(); + assert_eq!( + saved, + [SavedEffect { + icon: false, + ..SavedEffect::new("minecraft:speed", 100, 0) + }] + ); + let loaded = PlayerEffects::from_saved(&saved); + assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2); + } #[test] fn adding_counts_a_change_only_when_the_effect_changed() { let mut fx = PlayerEffects::default(); @@ -531,6 +655,21 @@ mod tests { assert_eq!(fx.get("speed").unwrap().duration, 49); assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1); } + #[test] + fn a_reloaded_effect_falls_back_to_the_one_it_hid() { + // /effect give poison 6 s level II over poison 30 s level I, saved + // and loaded: when level II runs out, level I resumes with what is + // left of its 30 s. + let mut fx = PlayerEffects::default(); + fx.add(EffectInstance::new("poison", 600, 0)); + fx.add(EffectInstance::new("poison", 120, 1)); + let mut loaded = PlayerEffects::from_saved(&fx.saved()); + for _ in 0..120 { + loaded.tick(); + } + let poison = loaded.get("poison").expect("level I resumed"); + assert_eq!((poison.amplifier, poison.duration), (0, 480)); + } /// The only test here that registers mod effects (once per process). #[test] fn a_mod_effect_saves_under_its_namespace() { @@ -546,11 +685,11 @@ mod tests { 1, )); let saved = fx.saved(); - assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]); + assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]); assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list); // Without its mod the saved effect is dropped, as an unknown id is. assert!( - PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)]) + PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)]) .list .is_empty() ); diff --git a/crates/horizon-client-render/src/fluid_render.rs b/crates/horizon-client-render/src/fluid_render.rs new file mode 100644 index 0000000000..f4881cf571 --- /dev/null +++ b/crates/horizon-client-render/src/fluid_render.rs @@ -0,0 +1,210 @@ +// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace. +// Copyright (c) 2022 The Create Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use horizon_core::geometry::Direction; + +/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint. +#[derive(Clone, Copy, Debug)] +pub struct NativeFluidRenderOptions { + pub argb: Option, + pub render_bottom: bool, + /// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension. + /// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas. + pub uv_shrink_ratio: f32, +} + +/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FluidVertex { + pub pos: [f32; 3], + pub uv: [f32; 2], + pub normal: [i8; 3], +} + +/// Preserve sky light and increase block light to the fluid's luminosity (native light packing). +pub fn fluid_light(light: u32, luminosity: u8) -> u32 { + (light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8) +} + +/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything. +/// Negative coordinates and exact integer boundaries retain the source's local UV fractions. +pub fn fluid_box( + bounds: [f32; 6], + options: NativeFluidRenderOptions, + mut emit: impl FnMut(FluidVertex), +) -> bool { + if !options.uv_shrink_ratio.is_finite() + || !(0.0..=1.0).contains(&options.uv_shrink_ratio) + || bounds + .iter() + .any(|x| !x.is_finite() || x.abs() > 1_048_576.0) + || (0..3).any(|i| bounds[i] > bounds[i + 3]) + { + return false; + } + let tiles = + std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64); + let quads = tiles[0] * tiles[1] * 2 + + tiles[1] * tiles[2] * 2 + + tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 }; + if quads > 65_536 { + return false; + } + let [x0, y0, z0, x1, y1, z1] = bounds; + for (side, face) in [ + (Direction::Down, [x0, z0, x1, z1, y0]), + (Direction::Up, [x0, z0, x1, z1, y1]), + (Direction::North, [x0, y0, x1, y1, z0]), + (Direction::South, [x0, y0, x1, y1, z1]), + (Direction::West, [z0, y0, z1, y1, x0]), + (Direction::East, [z0, y0, z1, y1, x1]), + ] { + if side != Direction::Down || options.render_bottom { + tiled_face(&mut emit, side, face, options.uv_shrink_ratio); + } + } + true +} + +fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) { + let [a0, b0, a1, b1, plane] = face; + let positive = matches!(side, Direction::Up | Direction::South | Direction::East); + let shrink = ratio * 0.25; + let lerp = |v| v + shrink * (0.5 - v); + let mut a = a0; + while a < a1 { + let end_a = (a.floor() + 1.0).min(a1); + let (u0, u1) = if matches!(side, Direction::North | Direction::East) { + (end_a.ceil() - end_a, end_a.ceil() - a) + } else { + (a - a.floor(), end_a - a.floor()) + }; + let (u0, u1) = (lerp(u0), lerp(u1)); + let mut b = b0; + while b < b1 { + let end_b = (b.floor() + 1.0).min(b1); + let (v0, v1) = if side == Direction::Up { + (b - b.floor(), end_b - b.floor()) + } else { + (end_b.ceil() - end_b, end_b.ceil() - b) + }; + let (v0, v1) = (lerp(v0), lerp(v1)); + let positions = match side { + Direction::West | Direction::East => { + let (near, far) = if positive { (end_a, a) } else { (a, end_a) }; + [ + [plane, end_b, near], + [plane, b, near], + [plane, b, far], + [plane, end_b, far], + ] + } + Direction::North | Direction::South => { + let (near, far) = if positive { (a, end_a) } else { (end_a, a) }; + [ + [near, end_b, plane], + [near, b, plane], + [far, b, plane], + [far, end_b, plane], + ] + } + Direction::Down | Direction::Up => { + let (near, far) = if positive { (b, end_b) } else { (end_b, b) }; + [ + [a, plane, near], + [a, plane, far], + [end_a, plane, far], + [end_a, plane, near], + ] + } + }; + let normal = match side { + Direction::Down => [0, -1, 0], + Direction::Up => [0, 1, 0], + Direction::North => [0, 0, -1], + Direction::South => [0, 0, 1], + Direction::West => [-1, 0, 0], + Direction::East => [1, 0, 0], + }; + for (pos, uv) in positions + .into_iter() + .zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]]) + { + out(FluidVertex { pos, uv, normal }); + } + b = end_b; + } + a = end_a; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn source_tiling_is_outward_and_does_not_inset_geometry() { + let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75]; + let mut result = Vec::new(); + assert!(fluid_box( + bounds, + NativeFluidRenderOptions { + argb: None, + render_bottom: false, + uv_shrink_ratio: 4.0 / 2048.0, + }, + |v| result.push(v), + )); + assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4); + for quad in result.as_chunks::<4>().0 { + let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos)); + assert!( + (b - a) + .cross(c - a) + .dot(glam::Vec3::from_array(quad[0].normal.map(f32::from))) + > 0.0 + ); + assert!( + quad.iter() + .all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3])) + ); + } + assert!( + result + .iter() + .any(|v| v.pos == [bounds[0], bounds[4], bounds[2]]) + ); + assert!(!fluid_box( + [0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0], + NativeFluidRenderOptions { + argb: None, + render_bottom: true, + uv_shrink_ratio: 0.0, + }, + |_| panic!("invalid geometry must fail before emitting"), + )); + } + #[test] + fn luminosity_changes_only_block_light() { + assert_eq!(fluid_light(0x20a0, 15), 0xf0a0); + assert_eq!(fluid_light(0x25a5, 15), 0xf0a0); + assert_eq!(fluid_light(0xe030, 3), 0xe030); + } +} diff --git a/crates/horizon-client-render/src/fog.rs b/crates/horizon-client-render/src/fog.rs index b35f064785..2c3f332eb0 100644 --- a/crates/horizon-client-render/src/fog.rs +++ b/crates/horizon-client-render/src/fog.rs @@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) { (fog.environmental_start, fog.environmental_end) = (1e9, 2e9); } } +/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where +/// the sky reaches the eye, halved in a biome without precipitation. +pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 { + let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0); + rain * sky * if biome_rains { 1.0 } else { 0.5 } +} /// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass` /// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s /// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render` @@ -428,10 +434,14 @@ pub fn update_rain_fog( biome_rains: bool, delta_ticks: f32, ) { - let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0); - let target = rain * sky * if biome_rains { 1.0 } else { 0.5 }; + let target = rain_fog_target(rain, sky_light, biome_rains); state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2; } +/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits +/// at its level and easing would wait for ticks that do not come. +pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) { + state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains); +} fn rgb(c: u32) -> [f32; 3] { [ (c >> 16 & 255) as f32 / 255.0, @@ -726,6 +736,30 @@ mod tests { assert_eq!(clear.environmental_end, settled.environmental_end); assert_eq!(clear.color, settled.color); } + /// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth + /// of the way, as before. + #[test] + fn settled_rain_fog_is_its_target_and_easing_is_unchanged() { + assert_eq!( + rain_fog_target(1.0, 15, false), + 0.5, + "a dry biome halves it" + ); + assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog"); + let start = FogState { + rain_fog_multiplier: 0.1, + ..FogState::default() + }; + let mut settled = start; + settle_rain_fog(&mut settled, 0.5, 15, true); + assert_eq!(settled.rain_fog_multiplier, 0.5); + let mut eased = start; + update_rain_fog(&mut eased, 0.5, 15, true, 1.0); + assert_eq!( + eased.rain_fog_multiplier.to_bits(), + (0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits() + ); + } #[test] fn rain_fog_pulls_fog_in() { let sky = Sky { diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index b872f3b4c4..8eebf6cae6 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -45,18 +45,21 @@ fn static_entity_shader() -> String { mod crumbling_tests; mod entity_batch; mod entity_outline; +mod fluid; #[cfg(test)] mod inter_stage_tests; +mod terrain_model; #[cfg(test)] pub(crate) mod test_gpu; mod weather; mod world_text; use entity_batch::EntityBatch; pub use entity_batch::{ - DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, - TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, - TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, - WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit, + DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY, + SHADOW_OVERLAY, TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, TERRAIN_MODEL_CUTOUT_OVERLAY, + TERRAIN_MODEL_SOLID_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, + TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, + WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain, mark_oit, }; pub use view::{View, ViewSet}; @@ -170,6 +173,7 @@ mod hdr_icc; mod hdr_stats; pub use hdr::Calibration as HdrCalibration; pub mod block_light; +mod custom_sky; mod displace; pub mod lightning; mod lod; @@ -469,9 +473,9 @@ struct SectionGpu { /// while its instance carries a fade (`drawn_faded`), and GPU culling's /// pass leaves it out. gpu_fading: bool, - /// When it began to fade in over the far terrain it replaces, which - /// stays whole behind it until then: a section new where far tiles are - /// drawn. Meshed again, it stays as it was. + /// When it came in over the far terrain it replaces (drawn whole at once, + /// `fade`): a section new where far tiles are drawn, which skips vanilla's + /// fade-in. Meshed again, it stays as it was. fading: Option, /// When it began to fade out, its column gone over far terrain that /// stays behind it (`Renderer::leave_section`); released once it has. @@ -493,17 +497,19 @@ pub(crate) fn collect_animated_layers( } impl SectionGpu { - /// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`: - /// its column has left the render distance, far terrain is coming in - /// under it (`lod::HELD`). - fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 { - match self.leaving { - Some(since) => lod::fade_out_code(since, 0, now), - None if retiring => lod::HELD, - None => self - .fading - .and_then(|since| lod::fade_code(Some(since), 0, now)) - .unwrap_or(0), + /// Its fade code this frame: 0, drawn whole, or `lod::HELD` (whole, in + /// front of far terrain) while it leaves (`leaving`) or its column has left + /// the render distance and far terrain is coming in under it (`retiring`). + /// A near section never dithers over far terrain: the holes of its pattern + /// showed its own inside (cave walls, the stone under the grass), nearer + /// than the tile behind, an x-ray of each column coming in or going out. + /// It comes in whole over the tile it replaces, which stays behind it until + /// `fading` is `lod::FADE` old, and leaves whole after as long. + fn fade(&self, retiring: bool) -> u32 { + if self.leaving.is_some() || retiring { + lod::HELD + } else { + 0 } } /// Its vertices in `arena`, and in `translucent_arena` when `split` (there @@ -569,23 +575,21 @@ impl SectionGpu { /// once false, this stays false until the section is meshed again, leaves, /// is held (`lod_retiring`), or the chunk fade-in time changes. fn drawn_faded(&self, key: SectionKey, f: FadeInputs<'_>) -> bool { - let fade = self.fade(f.now, f.retiring.contains(&key)); + let fade = self.fade(f.retiring.contains(&key)); let visibility = section_visibility(f.uploaded.get(&key), f.fade_ms, f.now); section_instance([0.0; 3], fade, visibility)[3].to_bits() != section_instance([0.0; 3], 0, 1.0)[3].to_bits() } - /// When its fade is over at the latest (`drawn_faded`): the later of the - /// ends of its fade-in over far terrain (`fading` and `lod::FADE`, a few - /// milliseconds after `fade` reaches 0) and of vanilla's (its first upload - /// and `f.fade_ms`). None while it leaves or is held, which ends only when - /// it goes or is meshed again, and without either fade-in. + /// When its fade is over at the latest (`drawn_faded`): the end of + /// vanilla's fade-in (its first upload and `f.fade_ms`; one coming in over + /// far terrain is whole at once, `fade`). None while it leaves or is held, + /// which ends only when it goes or is meshed again, and without a fade-in. fn fade_end(&self, key: SectionKey, f: FadeInputs<'_>) -> Option { if self.leaving.is_some() || f.retiring.contains(&key) { return None; } let vanilla = f.uploaded.get(&key).filter(|_| f.fade_ms > 0); - let vanilla = vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms)); - vanilla.max(self.fading.map(|t| t + lod::FADE)) + vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms)) } } /// Which of a mesh's ranges `move_off_page` moves: what it holds in the section @@ -1034,6 +1038,25 @@ pub struct ScreenshotRequest { /// `Renderer::screenshot_quality`, not the window's. pub scaled: bool, } +/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`), +/// for the frames HDR is off, and with it where the display offers HDR output: the +/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid +/// pass at Retina sizes). +pub struct SolidPipeline { + plain: wgpu::RenderPipeline, + hdr: Option, +} + +impl SolidPipeline { + /// The pipeline for a frame with HDR output on (`hdr`) or off. + pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline { + match &self.hdr { + Some(lit) if hdr => lit, + _ => &self.plain, + } + } +} + pub struct Renderer { instance: wgpu::Instance, adapter: wgpu::Adapter, @@ -1085,10 +1108,10 @@ pub struct Renderer { /// operation fails on a lost device (no error scope sees it). device_lost: Arc, pub config: wgpu::SurfaceConfiguration, - solid: wgpu::RenderPipeline, + solid: SolidPipeline, /// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`). - solid_opaque: wgpu::RenderPipeline, - solid_cutout: wgpu::RenderPipeline, + solid_opaque: SolidPipeline, + solid_cutout: SolidPipeline, translucent: wgpu::RenderPipeline, translucent_mark: wgpu::RenderPipeline, /// Flat section draws (`flat`), where multi-draw is emulated. @@ -1185,10 +1208,15 @@ pub struct Renderer { world_primitives: world_primitives::WorldPrimitives, text_polygon_offset_pipeline: wgpu::RenderPipeline, world_text_pipeline: wgpu::RenderPipeline, + fluid_pipeline: wgpu::RenderPipeline, + terrain_model_pipelines: [wgpu::RenderPipeline; 3], celestial_pipeline: wgpu::RenderPipeline, celestial_buffer: wgpu::Buffer, end_sky: end_sky::EndSky, stars: stars::Stars, + /// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game. + custom_sky: custom_sky::CustomSky, + custom_sky_draws: Vec, /// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`). entity_outline: entity_outline::EntityOutline, panorama: panorama::Panorama, @@ -1217,6 +1245,10 @@ pub struct Renderer { /// Ordinary entity models posed on the GPU; `None` without compute shaders. entity_skin: Option, group: wgpu::BindGroup, + fluid_group: wgpu::BindGroup, + fluid_sampler: wgpu::Sampler, + terrain_model_group: wgpu::BindGroup, + terrain_model_sampler: wgpu::Sampler, globals: wgpu::Buffer, /// Resource-pack normal/specular maps, split like the colour sprite arrays. pbr_maps: Option>, @@ -1380,9 +1412,12 @@ pub struct Renderer { hdr_output: Option, /// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or /// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the - /// device draws and blends into it. A 16-bit frame with HDR off since reaches the - /// swapchain through `readback`. + /// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame + /// with HDR off since reaches the swapchain through `readback`. frame_format: wgpu::TextureFormat, + /// Whether a pack picked or dropped since the renderer was made has been told it runs on + /// the frame that exists (`set_native_shader_pack`). + frame_kept_logged: bool, /// The marker file of an HDR output that has not presented a frame yet /// (`graphics::HDR_MARKER`). hdr_marker: Option, @@ -1449,6 +1484,8 @@ pub struct Renderer { pub screenshot: Option, /// Outcome of the last screenshot: frame size, or the error. pub screenshot_result: Option>, + /// The HDR copy the last screenshot wrote beside it, if it wrote one. + pub screenshot_hdr: Option, /// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many /// times the game's resolution (1, 2, 4 or 8). pub screenshot_quality: u32, @@ -1858,9 +1895,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay { } /// The backends to open a device on, each tried in turn until one gives a /// device. `api` is the player's choice (`GraphicsApi::with_env` applied); -/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set, -/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64, -/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other +/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the +/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself. +/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included, +/// then Vulkan, based on the device losses seen there (#7513). Other /// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then /// OpenGL. Each is tried when the one before has no surface, adapter or device. A native /// crash in the driver can't be @@ -1983,7 +2021,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature features.contains( wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES, - ) && format.allowed_usages.contains( + ) && draws_frame(format) +} + +/// Whether a format with the adapter's `format` features takes every usage and flag the +/// passes take of the frame. +fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool { + format.allowed_usages.contains( wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC @@ -1993,17 +2037,28 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature ) } -/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth -/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output -/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band -/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR. +/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md) +/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1 +/// survive from the world to the tonemap and the HDR pass. +const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws +/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output +/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else +/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`, +/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device +/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR +/// shows them. Else the swapchain's 8-bit format, as without HDR. fn frame_format( wide_frame: bool, hdr_offered: bool, device_draws_it: bool, swapchain: wgpu::TextureFormat, + linear: (bool, bool, bool), ) -> wgpu::TextureFormat { - if wide_frame && hdr_offered && device_draws_it { + if linear.2 && (linear.0 || linear.1 && hdr_offered) { + LINEAR_FRAME + } else if wide_frame && hdr_offered && device_draws_it { WIDE_FRAME } else { swapchain.remove_srgb_suffix() @@ -2024,7 +2079,8 @@ async fn open_device( for (order, allow_cpu) in device_search( api.with_env(), std::env::consts::OS, - std::env::consts::ARCH, + // The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513). + horizon_telemetry::system::machine_arch(), wgpu_backend, ) { match open_device_with(window, offscreen, &order, layers, allow_cpu).await { @@ -2476,6 +2532,8 @@ impl Renderer { translucent_batching: bool, wide_frame: bool, shader_pack: bool, + linear_pack: bool, + hdr_float: bool, ) -> Result { let Opened { instance, @@ -2609,7 +2667,23 @@ impl Renderer { adapter.get_texture_format_features(WIDE_FRAME), ); crate::graphics::offer_smooth_gradients(wide_ok); - let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format); + let linear = ( + linear_pack, + hdr_float, + draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)), + ); + if linear.0 && !linear.2 { + log::warn!( + "Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one" + ); + } + let frame_format = frame_format( + wide_frame, + hdr_output.is_some(), + wide_ok, + config.format, + linear, + ); if previous.is_none() { startup_step(format_args!("creating the pipelines")); } @@ -2708,6 +2782,20 @@ impl Renderer { bind_group_layouts: &[Some(&bindings)], ..Default::default() }); + let fluid_pipeline = fluid::pipeline( + &device, + Some(&layout), + &shader, + config.format.remove_srgb_suffix(), + &sprite_constants, + ); + let terrain_model_pipelines = terrain_model::pipelines( + &device, + Some(&layout), + &shader, + config.format.remove_srgb_suffix(), + &sprite_constants, + ); let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4]; let terrain_buffers = [ Some(wgpu::VertexBufferLayout { @@ -2729,16 +2817,24 @@ impl Renderer { step_mode: wgpu::VertexStepMode::Instance, attributes: &debug_line_attributes, })]; - let pipeline_in = |layout: &wgpu::PipelineLayout, - label: &str, - vertex: &str, - fragment: &str, - buffers: &[Option>], - topology: wgpu::PrimitiveTopology, - cull: Option, - depth_write: bool, - depth_test: bool, - blend: Option| { + // The solid terrain's fragment constants without HDR output's lighting, for the + // frames HDR is off (`SolidPipeline`). + let plain_constants = [ + sprite_constants[0], + sprite_constants[1], + ("HDR_LIGHTING", 0.0), + ]; + let pipeline_with = |constants: &[(&str, f64)], + layout: &wgpu::PipelineLayout, + label: &str, + vertex: &str, + fragment: &str, + buffers: &[Option>], + topology: wgpu::PrimitiveTopology, + cull: Option, + depth_write: bool, + depth_test: bool, + blend: Option| { kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(layout), @@ -2769,7 +2865,7 @@ impl Renderer { module: &shader, entry_point: Some(fragment), compilation_options: wgpu::PipelineCompilationOptions { - constants: &sprite_constants, + constants, ..Default::default() }, targets: &[Some(wgpu::ColorTargetState { @@ -2782,6 +2878,57 @@ impl Renderer { cache: None, }) }; + let pipeline_in = |layout: &wgpu::PipelineLayout, + label: &str, + vertex: &str, + fragment: &str, + buffers: &[Option>], + topology: wgpu::PrimitiveTopology, + cull: Option, + depth_write: bool, + depth_test: bool, + blend: Option| { + pipeline_with( + &sprite_constants, + layout, + label, + vertex, + fragment, + buffers, + topology, + cull, + depth_write, + depth_test, + blend, + ) + }; + // A solid terrain pipeline (back faces culled, depth written, no blend) without HDR + // output's lighting, and with it where the display offers HDR output. + let solid_in = |layout: &wgpu::PipelineLayout, + label: &str, + vertex: &str, + fragment: &str, + buffers: &[Option>]| { + let build = |constants: &[(&str, f64)]| { + pipeline_with( + constants, + layout, + label, + vertex, + fragment, + buffers, + wgpu::PrimitiveTopology::TriangleList, + Some(wgpu::Face::Back), + true, + true, + None, + ) + }; + SolidPipeline { + plain: build(&plain_constants), + hdr: hdr_output.is_some().then(|| build(&sprite_constants)), + } + }; let pipeline = |label: &str, vertex: &str, fragment: &str, @@ -2805,41 +2952,29 @@ impl Renderer { ) }; let tri = wgpu::PrimitiveTopology::TriangleList; - let solid = pipeline( + let solid = solid_in( + &layout, "Terrain solid", "vs_terrain", "fs_solid", &terrain_buffers, - tri, - Some(wgpu::Face::Back), - true, - true, - None, ); // `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the // world pass without a shader pack: no cutout first, through a shader that never // discards, then the rest. - let solid_opaque = pipeline( + let solid_opaque = solid_in( + &layout, "Terrain solid opaque", "vs_terrain_opaque", "fs_opaque", &terrain_buffers, - tri, - Some(wgpu::Face::Back), - true, - true, - None, ); - let solid_cutout = pipeline( + let solid_cutout = solid_in( + &layout, "Terrain solid cutout", "vs_terrain_cutout", "fs_solid", &terrain_buffers, - tri, - Some(wgpu::Face::Back), - true, - true, - None, ); // The water mirror's camera sees the world from the other side of the plane, which // flips the winding: its solid terrain culls the front. @@ -3219,6 +3354,34 @@ impl Renderer { &lightmap_sampler, &emission_stats.view, ); + let fluid_sampler = device.create_sampler(&fluid::SAMPLER); + let fluid_group = terrain_group( + &device, + "Still fluid resources", + &bindings, + &globals, + &sprite_views, + &fluid_sampler, + &lightmap_view, + &lightmap_sampler, + &emission_stats.view, + ); + let terrain_model_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Native cutout model (unmipped)"), + lod_max_clamp: 0.0, + ..fluid::SAMPLER + }); + let terrain_model_group = terrain_group( + &device, + "Native cutout model resources", + &bindings, + &globals, + &sprite_views, + &terrain_model_sampler, + &lightmap_view, + &lightmap_sampler, + &emission_stats.view, + ); let crosshair_group = terrain_group( &device, "Debug crosshair resources", @@ -3786,12 +3949,8 @@ impl Renderer { alpha_blend, false, ); - let world_text_pipeline = world_text::pipeline( - &device, - Some(&entity_layout), - &entity_shader, - config.format.remove_srgb_suffix(), - ); + let world_text_pipeline = + world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format); // RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with // terrain wins the tie in vanilla (the boat's floor over the trapdoor on the // ground it is stopped by, #5950). Terrain and entities are separate draw @@ -4360,6 +4519,7 @@ impl Renderer { layout, label, vertex, fragment, buffers, tri, cull, true, true, blend, ) }, + |layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]), ) }); // With flat draws' page groups the per-section solid draws read their vertices from @@ -4373,40 +4533,24 @@ impl Renderer { ..Default::default() }); let origins = [None, terrain_buffers[1].clone()]; - let pulled = |label: &str, vertex: &str, fragment: &str, cull| { + let solid = |label: &str, vertex: &str, fragment: &str| { + solid_in(&pulled_layout, label, vertex, fragment, &origins) + }; + ( + solid("Terrain solid", "vs_section", "fs_solid"), + solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"), + solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"), pipeline_in( &pulled_layout, - label, - vertex, - fragment, - &origins, - tri, - Some(cull), - true, - true, - None, - ) - }; - let back = wgpu::Face::Back; - ( - pulled("Terrain solid", "vs_section", "fs_solid", back), - pulled( - "Terrain solid opaque", - "vs_section_opaque", - "fs_opaque", - back, - ), - pulled( - "Terrain solid cutout", - "vs_section_cutout", - "fs_solid", - back, - ), - pulled( "Terrain solid (mirrored)", "vs_section", "fs_solid_mirror", - wgpu::Face::Front, + &origins, + tri, + Some(wgpu::Face::Front), + true, + true, + None, ), ) } @@ -4508,6 +4652,7 @@ impl Renderer { let uploads = upload::Uploads::new(&device); let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK); let stars = stars::Stars::new(&device, frame_format); + let custom_sky = custom_sky::CustomSky::new(&device, frame_format); let entity_outline = entity_outline::EntityOutline::new( &device, (&entity_shader, &entity_layout, &entity_vertex), @@ -4626,10 +4771,14 @@ impl Renderer { world_primitives, text_polygon_offset_pipeline, world_text_pipeline, + fluid_pipeline, + terrain_model_pipelines, celestial_pipeline, celestial_buffer, end_sky: end_sky::EndSky::default(), stars, + custom_sky, + custom_sky_draws: Vec::new(), entity_outline, panorama, border_pipeline, @@ -4656,6 +4805,10 @@ impl Renderer { entity_batch: EntityBatch::default(), entity_skin, group, + fluid_group, + fluid_sampler, + terrain_model_group, + terrain_model_sampler, globals, pbr_maps, lightmap, @@ -4743,6 +4896,7 @@ impl Renderer { emission_stats, hdr_output, frame_format, + frame_kept_logged: false, hdr_marker: None, pack_post: None, pack_loading: None, @@ -4775,6 +4929,7 @@ impl Renderer { started: Instant::now(), screenshot: None, screenshot_result: None, + screenshot_hdr: None, screenshot_quality: 1, screenshot_halvings: 0, gamma: 0.5, @@ -4845,6 +5000,15 @@ impl Renderer { } pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); } + /// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`). + pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) { + self.custom_sky.set_pack(&self.device, &self.queue, pack); + } + /// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each + /// frame, empty when the world's sky has none. + pub fn custom_sky_draws(&mut self) -> &mut Vec { + &mut self.custom_sky_draws + } /// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block /// outline, or the built-in one. pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) { @@ -6527,6 +6691,50 @@ impl Renderer { } Ok(report) } + /// Makes the running shader pack, of either kind, forget what it kept from earlier frames: + /// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack. + pub fn reset_shader_pack_history(&mut self) { + // A frame still on the submit thread uses the targets and readbacks replaced + // here. + self.land_frame(); + if let Some((_, Some(post))) = &mut self.pack_post { + post.reset_history(&self.device); + } + if let Some(native) = &mut self.native { + native.frame.reset_history(); + if let Some(post) = &mut native.post { + post.reset_history(); + } + } + } + /// The running shader pack's kind: `iris` (a `shaders/` pack) or `native` (one of + /// Horizon's own). + pub fn shader_pack_kind(&self) -> Option<&'static str> { + match (&self.pack_post, &self.native) { + (Some((_, Some(_))), _) => Some("iris"), + (_, Some(_)) => Some("native"), + _ => None, + } + } + /// `screenshot-sequence`: waits (30 seconds at most) for the frame just drawn to finish on + /// the GPU, and takes the centre depth a `shaders/` pack copied in it, which its next frame + /// reads: that frame's, or an error, never an older one. The last frame's PNG has already + /// waited for it, without a limit, as `screenshot` does. + pub fn finish_sequence_frame(&mut self) -> Result<()> { + self.device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(30)), + }) + .map_err(|e| format!("the frame didn't finish on the GPU: {e}"))?; + if self.device_lost() { + return Err("the graphics device was lost".into()); + } + if let Some((_, Some(post))) = &mut self.pack_post { + post.take_center_depth(&self.device)?; + } + Ok(()) + } /// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while /// the game keeps drawing without one: the driver compiles a big pack's programs for seconds. /// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it. @@ -6690,6 +6898,18 @@ impl Renderer { block_light::LightTable, )>, ) -> std::result::Result, String> { + let linear = pack + .as_ref() + .is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2); + if (linear || self.frame_format == LINEAR_FRAME) + && linear != (self.frame_format == LINEAR_FRAME) + && !std::mem::replace(&mut self.frame_kept_logged, true) + { + log::info!( + "Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it", + self.frame_format + ); + } let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else { self.native = None; return Ok(Vec::new()); @@ -6935,6 +7155,28 @@ impl Renderer { &self.lightmap_sampler, &self.emission_stats.view, ); + self.fluid_group = terrain_group( + &self.device, + "Still fluid resources", + &self.terrain_layout, + &self.globals, + &self.sprite_views, + &self.fluid_sampler, + &self.lightmap_view, + &self.lightmap_sampler, + &self.emission_stats.view, + ); + self.terrain_model_group = terrain_group( + &self.device, + "Native cutout model resources", + &self.terrain_layout, + &self.globals, + &self.sprite_views, + &self.terrain_model_sampler, + &self.lightmap_view, + &self.lightmap_sampler, + &self.emission_stats.view, + ); } /// Block sprite layers per array on this device: a sprite's array is its layer over it, its /// layer in that array the rest (`block_sprites::Lookup`). @@ -6981,6 +7223,24 @@ impl Renderer { planes: frustum(views.cull().view_proj(bob)), } } + /// A conservative view for geometry built before TAA chooses this frame's + /// jitter. Cover a whole rendered pixel on each side, at the configured + /// resolution scale/upscale, rather than borrowing the previous frame's size. + pub fn camera_frustum_with_jitter( + &self, + fov_degrees: f32, + yaw: f32, + pitch: f32, + bob: Mat4, + depth_far: f32, + ) -> Frustum { + let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far)); + taa_frustum( + views.cull().view_proj(bob), + self.world_post + .taa_render_size([self.config.width, self.config.height]), + ) + } fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View { let aspect = self.config.width as f32 / self.config.height.max(1) as f32; View::screen(yaw, pitch, fov_degrees, aspect, far) @@ -7955,7 +8215,7 @@ impl Renderer { let fading_over_lods = self.sections_fading_over_lods(now); let fade_code = move |section: &SectionGpu, key: &SectionKey| { if fading_over_lods { - section.fade(now, retiring.contains(key)) + section.fade(retiring.contains(key)) } else { 0 } @@ -8576,8 +8836,12 @@ impl Renderer { translucent_particle_quads, translucent_item_quads, oit_entity_quads, + fluid_quads, world_text_quads, world_text_offset_quads, + native_solid_quads, + native_cutout_quads, + native_cutout_mipped_quads, ] = self.entity_batch.update( entities, frame.additive, @@ -8604,13 +8868,23 @@ impl Renderer { + translucent_particle_quads + translucent_item_quads + oit_entity_quads + + fluid_quads + world_text_quads - + world_text_offset_quads; + + world_text_offset_quads + + native_solid_quads + + native_cutout_quads + + native_cutout_mipped_quads; laps.lap("r_draw_lists"); // Where the tail of materials the classic and the improved passes // both draw ends: the shadows, particles and items count back from it. - let base_quads = - world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads; + let base_quads = world_quads + - oit_entity_quads + - fluid_quads + - world_text_quads + - world_text_offset_quads + - native_solid_quads + - native_cutout_quads + - native_cutout_mipped_quads; let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds); let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) { let (rebuilt, uniform) = @@ -8854,6 +9128,12 @@ impl Renderer { brightness, ); } + self.custom_sky.prepare( + &mut self.uploads, + &mut encoder, + jittered, + &self.custom_sky_draws, + ); let celestial_vertices = frame.celestial.len().min(2048 * 4); if frame.end_sky && !frame.title { self.end_sky.prepare(&self.device, frame.end_sky_sprite); @@ -9462,6 +9742,8 @@ impl Renderer { } } if let Some(native) = &mut self.native { + let reflection = + reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view)); native .frame .set_reflection(&self.device, reflection.as_ref()); @@ -9495,7 +9777,9 @@ impl Renderer { .as_ref() .filter(|_| !frame.title) .and_then(|n| Some((n.material.as_ref()?, &n.frame.group))); - let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid); + // Without HDR output, the solid shaders without its lighting (`SolidPipeline`). + let hdr = self.hdr.is_some(); + let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid); // Two solid passes only where a discard costs the GPU its hidden surface // removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass // would double the solid draw calls and vertex work for nothing. @@ -9593,6 +9877,12 @@ impl Renderer { pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..)); pass.draw(0..sunrise_fan::VERTICES as u32, 0..1); } + // OptiFine custom sky layers, over the disc and sunrise and under the + // sun, moon and stars. + if self.custom_sky.is_drawing() { + self.custom_sky.draw(&mut pass); + pass.set_bind_group(0, &self.group, &[]); + } } } } @@ -9605,6 +9895,13 @@ impl Renderer { pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); if frame.end_sky { self.end_sky.draw(&mut pass); + // The End's custom sky layers go over its sky box. + if self.custom_sky.is_drawing() && !pack_draws_sky && self.native.is_none() + { + self.custom_sky.draw(&mut pass); + pass.set_pipeline(&self.celestial_pipeline); + pass.set_bind_group(0, &self.entity_group, &[]); + } } if celestial_vertices > 0 && !pack_draws_celestial { // The Overworld's bodies start with the sun's quad @@ -9720,14 +10017,14 @@ impl Renderer { let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat { Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)], None if split_solid => &[ - (&flat.solid_opaque, 0..opaque, 0..phase2_split), + (flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split), ( - &flat.solid_cutout, + flat.solid_cutout.get(hdr), cutout..flat.phase1_calls, phase2_split..phase2_calls, ), ], - None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)], + None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)], }; let draw = |pass: &mut wgpu::RenderPass<'_>| { for (pipeline, calls, _) in passes { @@ -9780,9 +10077,13 @@ impl Renderer { type Draws = std::ops::Range; let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material { None if split_solid => &[ - (&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout), ( - &self.solid_cutout, + self.solid_opaque.get(hdr), + 0..opaque, + phase1_draws..phase2_cutout, + ), + ( + self.solid_cutout.get(hdr), cutout..phase1_draws, phase2_cutout..solid_draws, ), @@ -10015,6 +10316,50 @@ impl Renderer { } pass.set_bind_group(0, &self.group, &[]); } + // Native terrain models use the block sprite arrays with their own + // alpha/mipmap policies and ordinary opaque depth. Storage follows + // the existing materials without moving their indices or frame tails. + let mut first = + world_quads - native_solid_quads - native_cutout_quads - native_cutout_mipped_quads; + for (i, count) in [ + native_solid_quads, + native_cutout_quads, + native_cutout_mipped_quads, + ] + .into_iter() + .enumerate() + { + if count > 0 { + pass.set_pipeline(&self.terrain_model_pipelines[i]); + pass.set_bind_group( + 0, + if i == 1 { + &self.terrain_model_group + } else { + &self.fluid_group + }, + &[], + ); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1); + } + first += count; + } + pass.set_bind_group(0, &self.group, &[]); + // Still-fluid block entities draw as their own ordinary sorted + // translucent material, before terrain transparency. This material + // always writes depth and never joins the improved-transparency queues. + if fluid_quads > 0 { + pass.set_pipeline(&self.fluid_pipeline); + pass.set_bind_group(0, &self.fluid_group, &[]); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed( + 0..fluid_quads * 6, + ((base_quads + oit_entity_quads) * 4) as i32, + 0..1, + ); + pass.set_bind_group(0, &self.group, &[]); + } if crumbling_quads > 0 && !oit_on { pass.set_pipeline(&self.crumbling_pipeline); pass.set_bind_group(0, &self.entity_group, &[]); @@ -10124,12 +10469,7 @@ impl Renderer { depth.size(), ); if let Some((color, _)) = &scaled { - let color = color.texture(); - encoder.copy_texture_to_texture( - color.as_image_copy(), - native.frame.opaque_color.as_image_copy(), - color.size(), - ); + native.frame.copy_opaque(&self.device, &mut encoder, color); } pass = world_pass_after_culling( &mut encoder, @@ -10406,7 +10746,7 @@ impl Renderer { pass.set_bind_group(0, &self.entity_group, &[]); pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); - let first = base_quads + oit_entity_quads; + let first = base_quads + oit_entity_quads + fluid_quads; for (pipeline, count, offset) in [ (&self.world_text_pipeline, world_text_quads, first), ( @@ -10761,12 +11101,18 @@ impl Renderer { { self.readback.present(&self.device, &mut encoder, image); } + // A 0.2 pack is shown at paper white: HDR output lifts nothing of its world. + let lifts_world = !frame.title + && self + .native + .as_ref() + .is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1); match (&self.hdr, &surface_frame) { (Some(hdr), Some(f)) => hdr.present( &self.queue, &mut encoder, &f.texture, - !frame.title, + lifts_world, self.own_timing.as_mut(), ), // Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for @@ -10785,7 +11131,7 @@ impl Renderer { .filter(|(request, _)| request.size.is_none()) .zip(self.hdr.as_ref()) .and_then(|(_, hdr)| { - let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?; + let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?; let size = [self.config.width, self.config.height]; Some(copy_frame(&self.device, &mut encoder, &texture, size)) }); @@ -10794,7 +11140,10 @@ impl Renderer { // submission needs this one: screenshots' readbacks, the error scopes (one per // thread), the first frame's startup log, VSync's frame callback, a reconfigure. // Frame generation presents its own frames (`frame_gen.present` below). + // A `screenshot-sequence` frame is submitted here, so it can wait for it to + // finish. let (encoder, surface_frame, after) = if readback.is_none() + && frame.pack.frame_timing.is_none() && fg_frames.is_none() && hdr_readback.is_none() && checks.is_none() @@ -10879,10 +11228,14 @@ impl Renderer { self.screenshot = Some(request); } else { self.screenshot_halvings = 0; + self.screenshot_hdr = None; if let Some(hdr_buffer) = hdr_readback { let hdr_path = hdr_screenshot_path(&request.path); match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) { - Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()), + Ok(()) => { + log::info!("Saved HDR screenshot to {}", hdr_path.display()); + self.screenshot_hdr = Some(hdr_path); + } Err(e) => log::warn!( "Horizon renderer: HDR screenshot {}: {e}", hdr_path.display() @@ -11917,6 +12270,16 @@ fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] { }); [a, b] } +fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum { + // TAA shifts clip x/y by at most half a rendered pixel times clip w. + // Widen x/y before extracting the planes: this includes every such shifted + // view, including a posed camera, without changing the near/far planes. + let [x, y] = rendered_size.map(|size| 1.0 / (1.0 + 2.0 / size.max(1) as f32)); + Frustum { + planes: frustum(Mat4::from_scale(Vec3::new(x, y, 1.0)) * view_proj), + } +} + fn frustum(m: Mat4) -> [Vec4; 6] { let r = |i| m.row(i); let planes = [ @@ -11932,6 +12295,57 @@ fn frustum(m: Mat4) -> [Vec4; 6] { #[cfg(test)] mod tests { + #[test] + fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() { + use super::*; + + let mut outside_unjittered = 0; + for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] { + for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] { + let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01)) + * Mat4::from_scale(Vec3::new(1.1, 0.9, 1.0)) + * crate::camera::view_matrix(yaw, pitch); + let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose; + let guarded = taa_frustum(matrix, size); + let ordinary = Frustum { + planes: frustum(matrix), + }; + for x in [-1.0, 1.0] { + for y in [-1.0, 1.0] { + let jitter = Mat4::from_translation(Vec3::new( + x / size[0] as f32, + y / size[1] as f32, + 0.0, + )); + let inverse = (jitter * matrix).inverse(); + for clip_x in [-1.0, 0.0, 1.0] { + for clip_y in [-1.0, 0.0, 1.0] { + for depth in [0.001, 0.5, 0.999] { + // Independent oracle: a point on an actual jittered + // viewport edge, transformed back into world space. + let point = inverse * Vec4::new(clip_x, clip_y, depth, 1.0); + let point = point.truncate() / point.w; + assert!(guarded.intersects_sphere(point, 1.0e-4)); + let slack = Vec3::splat(1.0e-4); + assert!(guarded.intersects(point - slack, point + slack)); + if !ordinary.intersects_sphere(point, 0.0) { + outside_unjittered += 1; + } + } + } + } + } + } + let behind = pose.inverse().transform_point3(Vec3::new(0.0, 0.0, 10.0)); + assert!(!guarded.intersects_sphere(behind, 0.3)); + } + } + assert!( + outside_unjittered > 0, + "the test must need the jitter guard" + ); + } + #[test] fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() { use super::*; @@ -12315,20 +12729,75 @@ mod tests { fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() { use super::frame_format; use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm}; - assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm); - for (wide_frame, hdr_offered, draws) in [ - (false, true, true), - (true, false, true), - (true, true, false), + // No float frame asked for (or a device without it) leaves it as before. + for linear in [ (false, false, false), + (false, false, true), + (true, false, false), + (false, true, false), + (true, true, false), ] { assert_eq!( - frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb), - Bgra8Unorm + frame_format(true, true, true, Bgra8Unorm, linear), + Rgba16Unorm + ); + for (wide_frame, hdr_offered, draws) in [ + (false, true, true), + (true, false, true), + (true, true, false), + (false, false, false), + ] { + assert_eq!( + frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear), + Bgra8Unorm + ); + assert_eq!( + frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear), + Rgba8Unorm + ); + } + } + } + /// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame + /// wherever the device draws it, HDR on or off. + #[test] + fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() { + use super::{LINEAR_FRAME, frame_format}; + use wgpu::TextureFormat::Bgra8UnormSrgb; + for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] { + assert_eq!( + frame_format( + wide_frame, + hdr_offered, + draws, + Bgra8UnormSrgb, + (true, false, true) + ), + LINEAR_FRAME + ); + } + } + /// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the + /// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as + /// without the switch. + #[test] + fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() { + use super::{LINEAR_FRAME, frame_format}; + use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb}; + for (wide_frame, draws) in [(true, true), (false, false)] { + assert_eq!( + frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)), + LINEAR_FRAME ); assert_eq!( - frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm), - Rgba8Unorm + frame_format( + wide_frame, + false, + draws, + Bgra8UnormSrgb, + (false, true, true) + ), + Bgra8Unorm ); } } @@ -12405,7 +12874,7 @@ mod tests { let mut batch = super::EntityBatch::default(); let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false); let vertices = &batch.combined; - assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]); + assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]); assert_eq!( vertices .as_chunks::<4>() @@ -12728,6 +13197,77 @@ mod tests { } } #[test] + fn plain_solid_shaders_translate_for_every_backend() { + // `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING` + // off on every backend: they must still translate (the other test translates them + // with the constants' defaults). + let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap(); + let info = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); + let stage = naga::ShaderStage::Fragment; + let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect(); + for entry in ["fs_solid", "fs_opaque"] { + let (module, info) = naga::back::pipeline_constants::process_overrides( + &module, + &info, + Some((stage, entry)), + &constants, + ) + .unwrap(); + let msl = naga::back::msl::write_string( + &module, + &info, + &naga::back::msl::Options { + fake_missing_bindings: true, + ..Default::default() + }, + &naga::back::msl::PipelineOptions { + entry_point: Some((stage, entry.to_owned())), + ..Default::default() + }, + ); + // Metal compiles the lighting out from this constant. + assert!( + msl.unwrap() + .0 + .contains("constant bool HDR_LIGHTING = false;"), + "{entry}" + ); + let mut hlsl = String::new(); + naga::back::hlsl::Writer::new( + &mut hlsl, + &Default::default(), + &naga::back::hlsl::PipelineOptions { + entry_point: Some((stage, entry.to_owned())), + }, + ) + .write(&module, &info, None) + .unwrap_or_else(|e| panic!("{entry}: {e}")); + let mut glsl = String::new(); + naga::back::glsl::Writer::new( + &mut glsl, + &module, + &info, + &naga::back::glsl::Options { + version: naga::back::glsl::Version::Desktop(330), + ..Default::default() + }, + &naga::back::glsl::PipelineOptions { + shader_stage: stage, + entry_point: entry.to_owned(), + multiview: None, + }, + Default::default(), + ) + .and_then(|mut w| w.write()) + .unwrap_or_else(|e| panic!("{entry}: {e}")); + } + } + #[test] fn entity_shader_validates_with_its_overlay_constants() { let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap(); naga::valid::Validator::new( @@ -13093,7 +13633,8 @@ mod tests { last = now; } assert!(set_by.iter().all(|&n| n > 20), "flags set {set_by:?}"); - assert!(handed_back > 2_000, "{handed_back} handed back"); + // Over a thousand: the half of the uploads over far terrain come in whole. + assert!(handed_back > 1_000, "{handed_back} handed back"); // Switched off with sections still flagged: none keeps a record or a // flag, and each is marked as while GPU culling is off. assert!(sim.records.fading() > 0, "none flagged at the end"); diff --git a/crates/horizon-client-render/src/gfx/arena.rs b/crates/horizon-client-render/src/gfx/arena.rs index 4807765cc9..9ca776feae 100644 --- a/crates/horizon-client-render/src/gfx/arena.rs +++ b/crates/horizon-client-render/src/gfx/arena.rs @@ -1040,7 +1040,14 @@ mod tests { cache: None, }) }; - let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline); + let pipeline = &pipeline; + let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| { + super::super::SolidPipeline { + plain: pipeline(layout, label, "", "", None, None, true), + hdr: None, + } + }; + let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid); let stride = std::mem::size_of::() as u64; let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN); translucent.page_limit = 4 * GRAIN; diff --git a/crates/horizon-client-render/src/gfx/custom_sky.rs b/crates/horizon-client-render/src/gfx/custom_sky.rs new file mode 100644 index 0000000000..b8f646feb3 --- /dev/null +++ b/crates/horizon-client-render/src/gfx/custom_sky.rs @@ -0,0 +1,416 @@ +//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack), +//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame +//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices +//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars. +use glam::Mat4; +use horizon_optifine::sky::{Blend, Face}; +use wgpu::util::DeviceExt; + +use crate::custom_sky::{CustomSky as Pack, Draw}; + +/// The blend modes in the shader's `mode` order (custom_sky.wgsl). +const MODES: [Blend; 9] = [ + Blend::Add, + Blend::Subtract, + Blend::Multiply, + Blend::Dodge, + Blend::Burn, + Blend::Screen, + Blend::Replace, + Blend::Overlay, + Blend::Alpha, +]; + +/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer. +const MAX_DRAWS: usize = 32; +/// Dynamic uniform offsets are 256-byte aligned on every backend. +const STRIDE: u64 = 256; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Uniform { + clip_from_sky: [[f32; 4]; 4], + brightness: f32, + mode: u32, + pad: [u32; 2], +} + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Vertex { + position: [f32; 3], + uv: [f32; 2], + cell: [f32; 4], +} + +pub(super) struct CustomSky { + pipelines: Vec, + layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + uniform: wgpu::Buffer, + vertices: wgpu::Buffer, + /// One bind group per layer texture of the pack. + groups: Vec, + /// This frame's layers: bind group and pipeline indices. + frame: Vec<(u16, u8)>, +} + +/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it. +fn blend(mode: Blend) -> wgpu::BlendComponent { + use wgpu::BlendFactor as F; + let (src_factor, dst_factor, operation) = match mode { + Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add), + Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract), + Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add), + // dst + dst * src: lightens. + Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add), + // dst * (1 - src): darkens. + Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add), + // src + dst * (1 - src) = 1 - (1 - src)(1 - dst). + Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add), + Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add), + // dst * src + src * dst. + Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add), + Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add), + }; + wgpu::BlendComponent { + src_factor, + dst_factor, + operation, + } +} + +/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV. +fn mesh() -> Vec { + let mut out = Vec::with_capacity(36); + for face in Face::ALL { + let cell = face.cell(); + let [u0, v0, u1, v1] = cell; + let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; + let corners = face.corners(); + for i in [0, 1, 2, 0, 2, 3] { + out.push(Vertex { + position: corners[i], + uv: uv[i], + cell, + }); + } + } + out +} + +impl CustomSky { + pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Custom sky bindings"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: wgpu::BufferSize::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Custom sky shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Custom sky layout"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4]; + let pipelines = MODES + .iter() + .map(|&mode| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Custom sky layer"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_custom_sky"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &attributes, + })], + }, + primitive: wgpu::PrimitiveState { + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_custom_sky"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + // The frame's alpha is HDR output's mark (hdr.wgsl): the sky + // under the layers keeps its own. + blend: Some(wgpu::BlendState { + color: blend(mode), + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) + }) + .collect(); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Custom sky"), + min_filter: wgpu::FilterMode::Linear, + mag_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Custom sky layers"), + size: STRIDE * MAX_DRAWS as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Custom sky box"), + contents: bytemuck::cast_slice(&mesh()), + usage: wgpu::BufferUsages::VERTEX, + }); + Self { + pipelines, + layout, + sampler, + uniform, + vertices, + groups: Vec::new(), + frame: Vec::new(), + } + } + + /// The pack's layer textures, replacing any before. + pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) { + let limit = device.limits().max_texture_dimension_2d; + self.groups = pack + .images + .iter() + .map(|image| { + // Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing. + let (width, height, rgba) = if image.width <= limit && image.height <= limit { + (image.width, image.height, &image.rgba[..]) + } else { + log::warn!( + "custom sky texture of {}x{} too large", + image.width, + image.height + ); + (1, 1, &[0u8; 4][..]) + }; + let texture = device.create_texture_with_data( + queue, + &wgpu::TextureDescriptor { + label: Some("Custom sky layer"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + rgba, + ); + let view = texture.create_view(&Default::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Custom sky layer"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &self.uniform, + offset: 0, + size: wgpu::BufferSize::new(std::mem::size_of::() as u64), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }) + }) + .collect(); + } + + /// Upload this frame's layers; `clip` is clip space from camera-relative world. + pub(super) fn prepare( + &mut self, + uploads: &mut super::upload::Uploads, + encoder: &mut wgpu::CommandEncoder, + clip: Mat4, + draws: &[Draw], + ) { + self.frame.clear(); + for draw in draws.iter().take(MAX_DRAWS) { + if usize::from(draw.image) >= self.groups.len() { + continue; + } + let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0); + let uniform = Uniform { + clip_from_sky: (clip * draw.rotation).to_cols_array_2d(), + brightness: draw.brightness.clamp(0.0, 1.0), + mode: mode as u32, + pad: [0; 2], + }; + let offset = STRIDE * self.frame.len() as u64; + uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform)); + self.frame.push((draw.image, mode as u8)); + } + } + + pub(super) fn is_drawing(&self) -> bool { + !self.frame.is_empty() + } + + /// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0 + /// changed. + pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) { + pass.set_vertex_buffer(0, self.vertices.slice(..)); + for (i, &(image, mode)) in self.frame.iter().enumerate() { + pass.set_pipeline(&self.pipelines[usize::from(mode)]); + pass.set_bind_group( + 0, + &self.groups[usize::from(image)], + &[(STRIDE * i as u64) as u32], + ); + pass.draw(0..36, 0..1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What a blend leaves a sky texel of `dst` with when the shader outputs `src` + /// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel. + fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 { + let k = a * b; + let src = match mode { + Blend::Multiply => 1.0 + (t - 1.0) * k, + Blend::Overlay => 0.5 + (t - 0.5) * k, + Blend::Replace | Blend::Alpha => t, + _ => t * k, + }; + let c = blend(mode); + let f = |factor: wgpu::BlendFactor| match factor { + wgpu::BlendFactor::One => 1.0, + wgpu::BlendFactor::Zero => 0.0, + wgpu::BlendFactor::Src => src, + wgpu::BlendFactor::OneMinusSrc => 1.0 - src, + wgpu::BlendFactor::Dst => dst, + wgpu::BlendFactor::SrcAlpha => k, + wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k, + other => panic!("{other:?}"), + }; + let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor)); + match c.operation { + wgpu::BlendOperation::Add => s + d, + wgpu::BlendOperation::ReverseSubtract => d - s, + other => panic!("{other:?}"), + } + } + + #[test] + fn every_mode_gives_its_documented_result() { + let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}"); + // Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8. + let (d, t) = (0.4, 0.8); + close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2); + close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4); + close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32); + close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2); + close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8); + close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64); + close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16); + close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25); + assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d); + assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d); + // Faded out, every mode but replace (drawn only when bright) leaves the sky alone. + for mode in MODES { + if mode != Blend::Replace { + close(mix(mode, d, t, 1.0, 0.0), d); + } + } + // Half faded: add adds half. + close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8); + } + + #[test] + fn the_shader_validates() { + let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); + } + + #[test] + fn the_box_covers_every_face_with_its_cell() { + let mesh = mesh(); + assert_eq!(mesh.len(), 36); + for v in &mesh { + let [u0, v0, u1, v1] = v.cell; + assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1])); + assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3); + } + } +} diff --git a/crates/horizon-client-render/src/gfx/custom_sky.wgsl b/crates/horizon-client-render/src/gfx/custom_sky.wgsl new file mode 100644 index 0000000000..fb4f0feacb --- /dev/null +++ b/crates/horizon-client-render/src/gfx/custom_sky.wgsl @@ -0,0 +1,61 @@ +// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a +// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is +// chosen so that blend gives the layer's documented mode at its brightness, and a layer at +// brightness 0 leaves the sky as it was. +struct Layer { + // Projection * view (camera-relative) * the layer's rotation. + clip_from_sky: mat4x4, + brightness: f32, + // horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order. + mode: u32, +} +@group(0) @binding(0) var layer: Layer; +@group(0) @binding(1) var sky_texture: texture_2d; +@group(0) @binding(2) var sky_sampler: sampler; + +struct Out { + @builtin(position) clip: vec4, + @location(0) uv: vec2, + // The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it. + @location(1) @interpolate(flat) cell: vec4, +} + +@vertex fn vs_custom_sky( + @location(0) position: vec3, + @location(1) uv: vec2, + @location(2) cell: vec4, +) -> Out { + var out: Out; + out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0); + out.uv = uv; + out.cell = cell; + return out; +} + +const ADD: u32 = 0u; +const SUBTRACT: u32 = 1u; +const MULTIPLY: u32 = 2u; +const DODGE: u32 = 3u; +const BURN: u32 = 4u; +const SCREEN: u32 = 5u; +const REPLACE: u32 = 6u; +const OVERLAY: u32 = 7u; +const ALPHA: u32 = 8u; + +@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4 { + let half = 0.5 / vec2(textureDimensions(sky_texture)); + let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half); + let t = textureSample(sky_texture, sky_sampler, uv); + let k = t.a * layer.brightness; + switch layer.mode { + // dst * src: white leaves the sky alone. + case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); } + // 2 * dst * src: grey leaves it alone. + case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); } + case REPLACE: { return vec4(t.rgb, 1.0); } + // mix(dst, src, alpha). + case ALPHA: { return vec4(t.rgb, k); } + // add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone. + default: { return vec4(t.rgb * k, 1.0); } + } +} diff --git a/crates/horizon-client-render/src/gfx/entity_batch.rs b/crates/horizon-client-render/src/gfx/entity_batch.rs index d1a9f8bca0..d66dc5c308 100644 --- a/crates/horizon-client-render/src/gfx/entity_batch.rs +++ b/crates/horizon-client-render/src/gfx/entity_batch.rs @@ -14,8 +14,24 @@ use super::Vertex; /// material at the end: the render types vanilla gives an `OitPipelineSet` /// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is /// ignored and they stay in the first one, in their order. -/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET. -const MATERIALS: usize = 12; +/// Sorted still-fluid geometry follows, then the two native world-canvas text materials, +/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages. +const MATERIALS: usize = 16; +pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42; +pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43; +pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44; +/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass. +pub fn is_native_terrain(layer: u32) -> bool { + matches!( + layer & 0xffff, + FLUID_OVERLAY + | TERRAIN_MODEL_SOLID_OVERLAY + | TERRAIN_MODEL_CUTOUT_OVERLAY + | TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY + ) +} +/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas. +pub const FLUID_OVERLAY: u32 = 41; /// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier /// (bits 16 to 23): the quad's render type has an `OitPipelineSet` /// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`, @@ -31,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38; /// (`OIT_ENTITY`): improved transparency draws them in its stages. pub fn mark_oit(quads: &mut [Vertex]) { for vertex in quads { - vertex.layer |= OIT_ENTITY; + if !is_native_terrain(vertex.layer) { + vertex.layer |= OIT_ENTITY; + } } } /// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`). @@ -60,12 +78,21 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40; /// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16 /// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones. fn material(layer: u32, oit: bool) -> usize { + match layer & 0xffff { + TERRAIN_MODEL_SOLID_OVERLAY => return 13, + TERRAIN_MODEL_CUTOUT_OVERLAY => return 14, + TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15, + _ => {} + } + if layer & 0xffff == FLUID_OVERLAY { + return 10; + } if oit && layer & OIT_ENTITY != 0 { return 9; } match layer & 0xffff { - WORLD_TEXT_OVERLAY => 10, - WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11, + WORLD_TEXT_OVERLAY => 11, + WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12, TEXT_POLYGON_OFFSET_OVERLAY => 1, 11 => 2, 12 => 3, @@ -148,10 +175,10 @@ impl EntityBatch { } } } - // RenderType.text sorts alpha-blended quads from far to near. Keep - // both world-canvas layers after existing materials (including eyes - // moved out of additive); front text uses polygon offset. - for runs in &self.runs[10..] { + // Fluid and RenderType.text sort their own alpha-blended quads far to + // near. Fluid precedes both world-canvas layers, after existing materials + // (including eyes moved out of additive). + for runs in &self.runs[10..13] { let start = self.combined.len(); for &(first, count) in runs { self.combined @@ -169,6 +196,13 @@ impl EntityBatch { distance(b).total_cmp(&distance(a)) }); } + // Native solid/cutout models are depth writing and keep their submission order. + for runs in &self.runs[13..] { + for &(first, count) in runs { + self.combined + .extend_from_slice(&entities[first * 4..(first + count) * 4]); + } + } if self.moved_additive == 0 { self.combined.extend_from_slice(additive); } else { @@ -189,7 +223,57 @@ mod tests { use super::*; use std::{hint::black_box, time::Instant}; #[test] - fn world_canvas_sorts_after_oit_eyes_before_frame_tails() { + fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() { + let quad = |layer, x| { + [Vertex { + layer, + pos: [x, 0.0, 0.0], + ..geometry(1, false)[0] + }; 4] + }; + let entities: Vec<_> = [ + (FLUID_OVERLAY, 10.0), + (WORLD_TEXT_OVERLAY, 11.0), + (TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0), + (TERRAIN_MODEL_SOLID_OVERLAY, 1.0), + (TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0), + (TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0), + (OIT_ENTITY, 6.0), + (0, 7.0), + ] + .into_iter() + .flat_map(|(layer, x)| quad(layer, x)) + .collect(); + let mut batch = EntityBatch::default(); + let counts = batch.update( + &entities, + &quad(0, 8.0), + &quad(0, 9.0), + &quad(0, 12.0), + true, + ); + assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]); + let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); + assert_eq!( + xs, + [7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0] + ); + assert_eq!( + batch.combined[16].layer, + TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16 + ); + } + #[test] + fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() { + let mut vertices = [geometry(1, false)[0]; 2]; + vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16; + vertices[1].layer = 3; + mark_oit(&mut vertices); + assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16); + assert_eq!(vertices[1].layer, 3 | OIT_ENTITY); + } + #[test] + fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() { let quad = |layer, x| { [Vertex { layer, @@ -204,6 +288,8 @@ mod tests { (WORLD_TEXT_OVERLAY, 4.0), (WORLD_TEXT_OVERLAY, 5.0), (WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0), + (FLUID_OVERLAY, 11.0), + (FLUID_OVERLAY | OIT_ENTITY, 12.0), ] .into_iter() .flat_map(|(layer, x)| quad(layer, x)) @@ -215,12 +301,14 @@ mod tests { let mut batch = EntityBatch::default(); assert_eq!( batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true), - [1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1] + [1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0] ); let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); assert_eq!( positions, - [1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0] + [ + 1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0 + ] ); } @@ -320,12 +408,12 @@ mod tests { let mut batch = EntityBatch::default(); // Off: the tag changes nothing, the quads keep their place. let counts = batch.update(&entities, &[], &[], &[], false); - assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); + assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); let xs: Vec = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]); // On: the tagged quads come last, in order. let counts = batch.update(&entities, &[], &[], &[], true); - assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]); + assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0]); let xs: Vec = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]); } diff --git a/crates/horizon-client-render/src/gfx/flat.rs b/crates/horizon-client-render/src/gfx/flat.rs index 09faa94bac..391c6e3e47 100644 --- a/crates/horizon-client-render/src/gfx/flat.rs +++ b/crates/horizon-client-render/src/gfx/flat.rs @@ -17,6 +17,7 @@ //! copied after the test, which tells which ones it saw: each run of a page //! reserves room for all of its draws, the copy packs the seen ones at its //! start, and the run is drawn indirectly with the count the copy reached. +use super::SolidPipeline; use super::arena::GRAIN; /// One section draw for `flatten.wgsl`: index count, first sorted index (or @@ -86,12 +87,12 @@ pub struct Call { pub struct Flat { /// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels /// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal). - pub solid: wgpu::RenderPipeline, + pub solid: SolidPipeline, /// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's /// `opaque_quad`): the quads with no cutout first, through a shader that never discards, /// then the rest. - pub solid_opaque: wgpu::RenderPipeline, - pub solid_cutout: wgpu::RenderPipeline, + pub solid_opaque: SolidPipeline, + pub solid_cutout: SolidPipeline, /// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`). pub solid_mirror: wgpu::RenderPipeline, pub translucent: wgpu::RenderPipeline, @@ -357,7 +358,8 @@ fn plan( impl Flat { /// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a /// terrain pipeline as the per-section ones, without the origin buffer, and without - /// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`). + /// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`); + /// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one. pub fn new( device: &wgpu::Device, terrain_layout: &wgpu::BindGroupLayout, @@ -372,6 +374,7 @@ impl Flat { Option, bool, ) -> wgpu::RenderPipeline, + solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline, ) -> Self { let vertex = wgpu::ShaderStages::VERTEX; let compute = wgpu::ShaderStages::COMPUTE; @@ -432,32 +435,23 @@ impl Flat { Self { // The solid draws read either layout of the solid quads (`mesher::packed`) from // the page; translucent ones keep their vertex buffer. - solid: pipeline( + solid: solid( &layout, "Terrain solid (flat)", "vs_section_flat", "fs_solid", - None, - back, - true, ), - solid_opaque: pipeline( + solid_opaque: solid( &layout, "Terrain solid opaque (flat)", "vs_section_flat_opaque", "fs_opaque", - None, - back, - true, ), - solid_cutout: pipeline( + solid_cutout: solid( &layout, "Terrain solid cutout (flat)", "vs_section_flat_cutout", "fs_solid", - None, - back, - true, ), // The mirror flips the winding: its solid terrain culls the front. solid_mirror: pipeline( diff --git a/crates/horizon-client-render/src/gfx/fluid.rs b/crates/horizon-client-render/src/gfx/fluid.rs new file mode 100644 index 0000000000..bf93d64e6d --- /dev/null +++ b/crates/horizon-client-render/src/gfx/fluid.rs @@ -0,0 +1,69 @@ +//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes. +pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor { + label: Some("Still fluid (nearest texels, linear mipmaps)"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + lod_min_clamp: 0.0, + lod_max_clamp: 32.0, + compare: None, + anisotropy_clamp: 1, + border_color: None, +}; + +pub(super) fn pipeline( + device: &wgpu::Device, + layout: Option<&wgpu::PipelineLayout>, + shader: &wgpu::ShaderModule, + format: wgpu::TextureFormat, + constants: &[(&str, f64)], +) -> wgpu::RenderPipeline { + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Still fluid"), + layout, + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_native_fluid"), + compilation_options: wgpu::PipelineCompilationOptions { + constants, + ..Default::default() + }, + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &attributes, + })], + }, + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_native_fluid"), + compilation_options: wgpu::PipelineCompilationOptions { + constants, + ..Default::default() + }, + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} diff --git a/crates/horizon-client-render/src/gfx/native_material.rs b/crates/horizon-client-render/src/gfx/native_material.rs index fb81fb97c3..0cb1a8ee96 100644 --- a/crates/horizon-client-render/src/gfx/native_material.rs +++ b/crates/horizon-client-render/src/gfx/native_material.rs @@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4(0.0); let tangent = horizon_material_tangent(in.base, in.uv, normal); - return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv, - translucent, horizon_sprite_tags(in.layer & 0x7fffu), + let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a), + vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap), + in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu), horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, - horizon_terrain_native_colored_light(in.position), in.displaced)).color; + horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color; + return vec4(horizon_line_out(out.rgb), out.a); } var horizon_material_face_normal: vec3; struct HorizonMaterialSolid { @@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3, uv: vec2, layer: u32, color: v let fluid = high >= 8u && (high & 7u) < 6u; let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0)); return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit, - vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid))); + vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid))); } "; @@ -349,12 +351,13 @@ fn shader_source( source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)], - vertex: bool, - displaced: bool, + (vertex, displaced): (bool, bool), + api: horizon_shader_api::Api, ) -> String { format!( - "{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}", + "{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}", vanilla_terrain(), + super::native_post::line_helpers(api), if vertex { DISPLACE_PACK } else { @@ -378,8 +381,9 @@ fn module( materials: &[(u32, u32)], vertex: bool, displaced: bool, + api: horizon_shader_api::Api, ) -> Result { - let text = shader_source(&stage.source, tags, materials, vertex, displaced); + let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api); naga::front::wgsl::parse_str(&text) .map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text))) } @@ -403,6 +407,7 @@ impl NativeMaterial { shape.sprite_materials, vertex, displaced.is_some(), + frame.api, )?; let mut constants = present_constants(&module, constants); let sprite_names = [ @@ -562,7 +567,7 @@ impl NativeMaterial { #[cfg(test)] mod tests { use super::*; - use horizon_shader_api::{MANIFEST, Pack, StageKind, check}; + use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check}; fn stage(material: &str) -> CheckedStage { let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\ @@ -609,7 +614,7 @@ mod tests { let tags = [(7, 1), (3, 1), (9, 2)]; let materials = [(5, 1000)]; assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }")); - let module = module(&stage, &tags, &materials, false, false).unwrap(); + let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap(); let info = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all(), @@ -622,8 +627,8 @@ mod tests { &stage.source, &tags, &materials, - false, - false + (false, false), + Api::V0_1 )) ) }); @@ -647,6 +652,33 @@ mod tests { ); } + /// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes. + #[test] + fn the_terrain_validates_with_a_0_2_pack() { + let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\ + [material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n"; + let material = "fn material(input: MaterialInput) -> Surface {\n \ + return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n"; + let vertex = "fn vertex(input: VertexInput) -> vec3 {\n \ + return input.position + input.tint.rgb;\n}\n"; + let files = [ + (MANIFEST, manifest), + ("material.wgsl", material), + ("vertex.wgsl", vertex), + ] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + let report = check(&Pack::from_files(files).unwrap()).unwrap(); + let stage = report.stages.iter().find(|s| s.kind == StageKind::Material); + let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); + } + #[test] fn the_displaced_grid_validates_with_a_pack() { let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\ @@ -661,14 +693,14 @@ mod tests { let mut report = check(&Pack::from_files(files).unwrap()).unwrap(); let stage = report.stages.remove(0); let materials = [(5, 1000)]; - let module = module(&stage, &[], &materials, false, true).unwrap(); + let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap(); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all(), ) .validate(&module) .unwrap_or_else(|e| { - let text = shader_source(&stage.source, &[], &materials, false, true); + let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1); panic!("{}", e.emit_to_string(&text)) }); for entry in [ @@ -681,4 +713,116 @@ mod tests { ); } } + + /// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2, + /// its colours decoded on the way in and encoded on the way out as the entries do, draws + /// what 0.1's does (vanilla's) within an 8-bit step. + #[test] + fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() { + let instance = crate::gfx::test_gpu::instance(); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { + return; + }; + let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default())) + else { + return; + }; + // The entries' call of `material`, with a 16×16 grid of colours. + let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0); +} +@fragment fn fs(@builtin(position) p: vec4) -> @location(0) vec4 { + let c = floor(p.xy) / 15.0; + let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0); + let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0); + let lightmap = vec3(c.y, 0.8, 1.0 - c.y); + let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a), + vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap), + vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0), + vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color; + return vec4(horizon_line_out(out.rgb), out.a); +} +"; + let vanilla = "fn material(input: MaterialInput) -> Surface {\n \ + return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n"; + let format = wgpu::TextureFormat::Rgba8Unorm; + let draw = |api: Api| { + let manifest = format!( + "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\ + [material]\nfile = \"material.wgsl\"\n" + ); + let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + let report = check(&Pack::from_files(files).unwrap()).unwrap(); + let source = report.stages[0].source.clone() + + horizon_shader_api::stages::PBR_STUB + + super::super::native_post::line_helpers(api) + + entry; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(format.into())], + }), + multiview_mask: None, + cache: None, + }); + let size = wgpu::Extent3d { + width: 16, + height: 16, + depth_or_array_layers: 1, + }; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let mut encoder = device.create_command_encoder(&Default::default()); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: Default::default(), + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.draw(0..3, 0..1); + drop(pass); + let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]); + queue.submit([encoder.finish()]); + screenshot + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + screenshot.slice(..).get_mapped_range().unwrap().to_vec() + }; + let (old, new) = (draw(Api::V0_1), draw(Api::V0_2)); + let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(worst.is_some_and(|w| w <= 1), "{worst:?}"); + } } diff --git a/crates/horizon-client-render/src/gfx/native_pack.rs b/crates/horizon-client-render/src/gfx/native_pack.rs index eeadb7e79e..66645bdd89 100644 --- a/crates/horizon-client-render/src/gfx/native_pack.rs +++ b/crates/horizon-client-render/src/gfx/native_pack.rs @@ -101,7 +101,15 @@ impl NativePack { let settings = chosen .as_ref() .map(|settings| super::native_shadow::fitted(device, settings)); - let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr); + let api = report.manifest.api; + let mut frame = NativeFrame::new( + device, + settings.as_ref(), + textures, + lights, + terrain.pbr, + api, + ); // `[displacement]`, unless its option is off; nothing to displace without heights. let displacement = report .manifest @@ -222,8 +230,13 @@ impl NativePack { ) }) .transpose()?; - let post = (!post.is_empty()) - .then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame)) + // A 0.2 pack always has one: its decode and tonemap run without a chain too. + let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap); + let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2) + .then(|| { + let manifest = &report.manifest; + NativePost::new(device, post, manifest, constants, format, &frame, tonemap) + }) .transpose()?; let mut labels: Vec = sky.iter().map(|s| s.label.clone()).collect(); labels.extend( diff --git a/crates/horizon-client-render/src/gfx/native_post.rs b/crates/horizon-client-render/src/gfx/native_post.rs index d38221e985..cfea90fefe 100644 --- a/crates/horizon-client-render/src/gfx/native_post.rs +++ b/crates/horizon-client-render/src/gfx/native_post.rs @@ -7,11 +7,209 @@ //! (`NativeFrame`). use glam::{Mat4, Vec3}; -use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP}; +use horizon_shader_api::{ + Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP, +}; use super::Frame; -const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; + +/// What the renderer's own entries call where a stage's colours cross into and out of it: as +/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and +/// `horizon_encode` (the shadow pass has no prelude, so these stand alone). +pub fn line_helpers(api: Api) -> &'static str { + match api { + Api::V0_1 => { + "fn horizon_line_in(c: vec3) -> vec3 { return c; } +fn horizon_line_out(c: vec3) -> vec3 { return c; } +" + } + Api::V0_2 => { + "fn horizon_line_in(c: vec3) -> vec3 { + return pow(select(vec3(0.0), c, c > vec3(0.0)), vec3(2.2)); +} +fn horizon_line_out(c: vec3) -> vec3 { + return pow(select(vec3(0.0), c, c > vec3(0.0)), vec3(1.0 / 2.2)); +} +" + } + } +} + +/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of +/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror). +#[derive(Clone)] +pub struct Decoder(wgpu::RenderPipeline); + +impl Decoder { + fn new(device: &wgpu::Device) -> Self { + let source = line_helpers(Api::V0_2).to_owned() + + "@group(0) @binding(0) var horizon_from: texture_2d; +@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0); +} +@fragment fn horizon_fs(@builtin(position) p: vec4) -> @location(0) vec4 { + return vec4(horizon_line_in(textureLoad(horizon_from, vec2(p.xy), 0).rgb), 1.0); +} +"; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Shader API 0.2 decode"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let entries = (None, "horizon_vs", "horizon_fs"); + Self(fullscreen_pipeline( + device, + "Shader API 0.2 decode", + &module, + entries, + INTERMEDIATE, + &[], + )) + } + + /// Draws `from` decoded onto `to`. + pub fn run( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + from: &wgpu::TextureView, + to: &wgpu::TextureView, + ) { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader API 0.2 decode"), + layout: &self.0.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(from), + }], + }); + fullscreen_pass( + encoder, + "Shader API 0.2 decode", + to, + &self.0, + (0, &group), + None, + ); + } +} + +/// A full-screen pipeline `label` of `module`'s entries `(vertex, fragment)`, unblended. +fn fullscreen_pipeline( + device: &wgpu::Device, + label: &str, + module: &wgpu::ShaderModule, + (layout, vertex, fragment): (Option<&wgpu::PipelineLayout>, &str, &str), + format: wgpu::TextureFormat, + constants: &[(&str, f64)], +) -> wgpu::RenderPipeline { + let options = wgpu::PipelineCompilationOptions { + constants, + ..Default::default() + }; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout, + vertex: wgpu::VertexState { + module, + entry_point: Some(vertex), + compilation_options: options.clone(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fragment), + compilation_options: options, + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +/// One full-screen triangle of `pipeline` onto `view`, cleared first. +fn fullscreen_pass( + encoder: &mut wgpu::CommandEncoder, + label: &str, + view: &wgpu::TextureView, + pipeline: &wgpu::RenderPipeline, + (index, group): (u32, &wgpu::BindGroup), + timing: Option<&mut super::pack_timing::PassTiming>, +) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + timestamp_writes: timing.and_then(|t| t.writes(label)), + ..Default::default() + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(index, group, &[]); + pass.draw(0..3, 0..1); +} + +/// The tonemap stage's entries as the renderer draws them: the check's, with the result +/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. +fn tonemap_entries() -> String { + StageKind::Tonemap.entries(Api::V0_2).replace( + "return vec4(min(horizon_sanitize(out), vec3(frame.headroom)), 1.0);", + "return vec4(horizon_encode(min(horizon_sanitize(out), vec3(frame.headroom))), 1.0);", + ) +} + +/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the +/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output). +const DEFAULT_TONEMAP: &str = + "fn tonemap(input: TonemapInput) -> vec3 { return input.color; }\n"; + +/// A colour target of `INTERMEDIATE` and `size`, drawn and read. +fn intermediate(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::TextureView { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0].max(1), + height: size[1].max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: INTERMEDIATE, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) +} + +/// A 0.2 pack's display (docs/api/shaders-reference-0.2.md): the world decoded into +/// `scene_color` (the first pass's `previous`), the chain ending in `picture`, and the tonemap +/// stage drawing that onto the screen. +struct Display { + decoder: Decoder, + /// The pack's `[tonemap]` label, `None` for Horizon's. + label: Option, + tonemap: wgpu::RenderPipeline, + /// `scene_color` at the world's size; `picture` at the window's (none without a chain: + /// the tonemap reads `scene_color`). + scene: Option<([u32; 2], wgpu::TextureView)>, + picture: Option<([u32; 2], wgpu::TextureView)>, +} /// The world's colour and depth views a set of bind groups was made for. type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView); @@ -98,6 +296,8 @@ pub struct NativePost { named: Option<([u32; 2], Vec)>, /// The screen's colour format, the world target's too. format: wgpu::TextureFormat, + /// Under 0.2: the decode, the picture and the tonemap. + display: Option, } /// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and @@ -145,6 +345,11 @@ pub struct NativeFrame { frames: u32, /// `frame.wetness`, and when it was last written. wetness: (f32, Option), + /// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it + /// decoded (`linear_mirror`). + pub api: Api, + pub decoder: Option, + mirror: Option, } /// A depth texture of `size` the world's depth is copied into. @@ -179,7 +384,10 @@ fn copy_target( sample_count: 1, dimension: wgpu::TextureDimension::D2, format, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + // RENDER_ATTACHMENT: 0.2 draws `opaque_color` decoded rather than copying it. + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }) } @@ -225,6 +433,7 @@ impl NativeFrame { textures: Vec<(wgpu::TextureView, wgpu::Sampler)>, lights: super::block_light::LightTable, pbr: Option<&[wgpu::Texture; 2]>, + api: Api, ) -> Self { let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Native shader pack frame"), @@ -290,7 +499,7 @@ impl NativeFrame { min_filter: wgpu::FilterMode::Linear, ..Default::default() }); - let shadow_map = super::native_shadow::ShadowMap::new(device, shadow); + let shadow_map = super::native_shadow::ShadowMap::new(device, shadow, api); let block_light = super::block_light::BlockLight::new(device, lights); let array_view = |texture: &wgpu::Texture| { texture.create_view(&wgpu::TextureViewDescriptor { @@ -347,6 +556,9 @@ impl NativeFrame { shadow_map, shadow_camera: shadow.map(super::native_shadow::camera), reflects: false, + api, + decoder: (api == Api::V0_2).then(|| Decoder::new(device)), + mirror: None, last: None, eye: [f32::NAN; 4], history_reset: true, @@ -460,6 +672,7 @@ impl NativeFrame { ) { let size = [depth.width(), depth.height()]; let (color_size, format) = match color { + Some(c) if self.decoder.is_some() => ([c.width(), c.height()], INTERMEDIATE), Some(c) => ([c.width(), c.height()], c.format()), None => ([1, 1], self.opaque_color.format()), }; @@ -478,6 +691,46 @@ impl NativeFrame { self.regroup(device); } + /// Fills `opaque_color` from the world's colour `color`: a copy, under 0.2 decoded. + pub fn copy_opaque( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + ) { + match &self.decoder { + Some(d) => { + let to = self.opaque_color.create_view(&Default::default()); + d.run(device, encoder, color, &to); + } + None => encoder.copy_texture_to_texture( + color.texture().as_image_copy(), + self.opaque_color.as_image_copy(), + color.texture().size(), + ), + } + } + + /// The water mirror `view` as the material reads it: under 0.2 decoded into a copy. + pub fn linear_mirror( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + view: wgpu::TextureView, + ) -> wgpu::TextureView { + let Some(decoder) = &self.decoder else { + return view; + }; + let size = [view.texture().width(), view.texture().height()]; + let fits = |m: &wgpu::TextureView| [m.texture().width(), m.texture().height()] == size; + if !self.mirror.as_ref().is_some_and(fits) { + self.mirror = Some(intermediate(device, "Water mirror, linear", size)); + } + let mirror = self.mirror.clone().unwrap(); + decoder.run(device, encoder, &view, &mirror); + mirror + } + /// Sizes the surface target: the world's while a material stage writes it, else a blank. pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) { if [self.surface.width(), self.surface.height()] != size { @@ -543,6 +796,10 @@ impl NativeFrame { self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now)); let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref()); inputs.wetness = self.wetness.0; + if self.api == Api::V0_2 { + inputs.sky_color = decoded(inputs.sky_color); + inputs.fog_color = decoded(inputs.fog_color); + } if !self.reflects { inputs.has_reflection = 0; } @@ -592,6 +849,15 @@ impl NativeFrame { inputs } + /// Forgets the frames before, as a freshly loaded pack: the post stage's next `history` is + /// black, eased values start at their targets, wetness dry, the frame count at 0. + pub(super) fn reset_history(&mut self) { + self.last = None; + self.frames = 0; + self.wetness = (0.0, None); + self.start = std::time::Instant::now(); + } + /// Takes the post stage's pending history reset. It stays pending if post rendering is /// skipped for a frame, so the first frame it does render still sees black history. pub(super) fn take_history_reset(&mut self) -> bool { @@ -684,7 +950,8 @@ fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { impl NativePost { /// Pipelines for `stages` (the pack's post passes, in order) with the option values - /// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused. + /// `constants`, drawing onto a screen of `format`; under 0.2 the last onto `picture`, and + /// `tonemap` (else Horizon's) onto the screen. Errors name the pass wgpu refused. pub fn new( device: &wgpu::Device, stages: Vec, @@ -692,6 +959,7 @@ impl NativePost { constants: &[(String, f64)], format: wgpu::TextureFormat, frame: &NativeFrame, + tonemap: Option<&CheckedStage>, ) -> Result { let targets = manifest.post_targets(); let outputs: Vec> = manifest @@ -764,7 +1032,7 @@ impl NativePost { entry_point: Some("horizon_fs_post"), compilation_options: options, targets: &[Some(wgpu::ColorTargetState { - format: if plan[i].writes == Writes::Screen { + format: if plan[i].writes == Writes::Screen && frame.decoder.is_none() { format } else { INTERMEDIATE @@ -781,6 +1049,41 @@ impl NativePost { } passes.push((stage.label, pipeline)); } + let display = match &frame.decoder { + Some(decoder) => { + let source = tonemap.map_or_else( + || StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP, + |s| s.source.clone(), + ) + &tonemap_entries(); + let file = tonemap.map_or("", |s| s.file.as_str()); + let module = naga::front::wgsl::parse_str(&source) + .map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?; + let present = present_constants(&module, constants); + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Tonemap"), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); + let entries = ( + Some(&pipeline_layout), + "horizon_vs_tonemap", + "horizon_fs_tonemap", + ); + let pipeline = + fullscreen_pipeline(device, "Tonemap", &module, entries, format, &present); + if let Some(e) = pollster::block_on(scope.pop()) { + return Err(format!("{file}: {e}")); + } + Some(Display { + decoder: decoder.clone(), + label: tonemap.map(|s| s.label.clone()), + tonemap: pipeline, + scene: None, + picture: None, + }) + } + None => None, + }; let sampler = |label, filter| { device.create_sampler(&wgpu::SamplerDescriptor { label: Some(label), @@ -811,12 +1114,25 @@ impl NativePost { named: None, plan, format, + display, }) } - /// The passes' labels, in order. + /// The passes' labels, in order, then the pack's tonemap's. pub fn labels(&self) -> Vec<&str> { - self.passes.iter().map(|(l, _)| l.as_str()).collect() + let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref()); + self.passes + .iter() + .map(|(l, _)| l.as_str()) + .chain(tonemap) + .collect() + } + + /// Forgets the pack's own targets (`[[post]] output`): made again, blank, on the next frame, + /// as for a pack just made. Its pipelines stay. + pub(super) fn reset_history(&mut self) { + self.named = None; + self.groups = None; } fn prepare( @@ -824,8 +1140,26 @@ impl NativePost { device: &wgpu::Device, world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView), size: [u32; 2], + window: [u32; 2], history_reset: bool, ) { + if let Some(d) = &mut self.display { + if d.scene.as_ref().is_none_or(|s| s.0 != size) { + d.scene = Some(( + size, + intermediate(device, "Shader API 0.2 scene_color", size), + )); + self.groups = None; + } + let chain = !self.passes.is_empty(); + if chain && d.picture.as_ref().is_none_or(|p| p.0 != window) { + d.picture = Some(( + window, + intermediate(device, "Shader API 0.2 picture", window), + )); + self.groups = None; + } + } if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) { let views = self .scales @@ -913,85 +1247,98 @@ impl NativePost { } .create_view(&Default::default()); let blank = self.history_blank.create_view(&Default::default()); - let groups = (0..self.passes.len()) + // Under 0.2 the passes read the world decoded. + let scene = self + .display + .as_ref() + .and_then(|d| d.scene.as_ref()) + .map_or(world.0, |s| &s.1); + let group = |previous: &wgpu::TextureView, writes: Option| { + let mut entries = vec![ + wgpu::BindGroupEntry { + binding: 0, + resource: self.frame.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(scene), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(world.1), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(previous), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(&self.linear), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::Sampler(&self.nearest), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::TextureView(&self.shadow.0), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::Sampler(&self.shadow.1), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&history), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::TextureView(&self.shadow.2), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::TextureView(world.2), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView(&self.shadow.3), + }, + ]; + entries.extend(pack_texture_group_entries(&self.textures)); + // The pack's targets, a blank one in place of the one this pass writes. + if let Some((_, views)) = &self.named { + entries.extend(views.iter().enumerate().map(|(j, view)| { + let view = if writes == Some(Writes::Target(j)) { + &blank + } else { + view + }; + wgpu::BindGroupEntry { + binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32, + resource: wgpu::BindingResource::TextureView(view), + } + })); + } + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Native shader pack post"), + layout: &self.layout, + entries: &entries, + }) + }; + let mut groups: Vec<_> = (0..self.passes.len()) .map(|i| { // The chain's picture so far: the world's before its first pass. let previous = match (&self.targets, self.plan[i].reads) { (Some((_, t)), Some(slot)) => &t[slot], - _ => world.0, + _ => scene, }; - let mut entries = vec![ - wgpu::BindGroupEntry { - binding: 0, - resource: self.frame.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(world.0), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::TextureView(world.1), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: wgpu::BindingResource::TextureView(previous), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: wgpu::BindingResource::Sampler(&self.linear), - }, - wgpu::BindGroupEntry { - binding: 5, - resource: wgpu::BindingResource::Sampler(&self.nearest), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::TextureView(&self.shadow.0), - }, - wgpu::BindGroupEntry { - binding: 7, - resource: wgpu::BindingResource::Sampler(&self.shadow.1), - }, - wgpu::BindGroupEntry { - binding: 8, - resource: wgpu::BindingResource::TextureView(&history), - }, - wgpu::BindGroupEntry { - binding: 9, - resource: wgpu::BindingResource::TextureView(&self.shadow.2), - }, - wgpu::BindGroupEntry { - binding: 11, - resource: wgpu::BindingResource::TextureView(world.2), - }, - wgpu::BindGroupEntry { - binding: 10, - resource: wgpu::BindingResource::TextureView(&self.shadow.3), - }, - ]; - entries.extend(pack_texture_group_entries(&self.textures)); - // The pack's targets, a blank one in place of the one this pass writes. - if let Some((_, views)) = &self.named { - entries.extend(views.iter().enumerate().map(|(j, view)| { - let view = if self.plan[i].writes == Writes::Target(j) { - &blank - } else { - view - }; - wgpu::BindGroupEntry { - binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32, - resource: wgpu::BindingResource::TextureView(view), - } - })); - } - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Native shader pack post"), - layout: &self.layout, - entries: &entries, - }) + group(previous, Some(self.plan[i].writes)) }) .collect(); + // The tonemap's, last: the chain's picture, else the decoded world. + if let Some(d) = &self.display { + groups.push(group(d.picture.as_ref().map_or(scene, |p| &p.1), None)); + } self.groups = Some(( (world.0.clone(), world.1.clone(), world.2.clone()), blank_history, @@ -1011,18 +1358,34 @@ impl NativePost { history_reset: bool, mut timing: Option<&mut super::pack_timing::PassTiming>, ) { - self.prepare(device, world, size, history_reset); + let window = [screen.texture().width(), screen.texture().height()]; + self.prepare(device, world, size, window, history_reset); if let Some(t) = timing.as_deref_mut() { t.mark(device, encoder, (world.0, self.format)); } let Some((_, _, groups)) = &self.groups else { return; }; + if let Some(Display { + decoder, + scene: Some((_, scene)), + .. + }) = &self.display + { + decoder.run(device, encoder, world.0, scene); + } + let last = match &self.display { + Some(Display { + picture: Some((_, picture)), + .. + }) => picture, + _ => screen, + }; for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() { let view = match (self.plan[i].writes, &self.targets, &self.named) { (Writes::Chain(slot), Some((_, t)), _) => &t[slot], (Writes::Target(j), _, Some((_, n))) => &n[j], - _ => screen, + _ => last, }; let attachments = [Some(wgpu::RenderPassColorAttachment { view, @@ -1043,9 +1406,13 @@ impl NativePost { pass.set_bind_group(GROUP, group, &[]); pass.draw(0..3, 0..1); } + if let (Some(d), Some(group)) = (&self.display, groups.last()) { + let label = d.label.as_deref().unwrap_or("Tonemap"); + fullscreen_pass(encoder, label, screen, &d.tonemap, (GROUP, group), timing); + } // The chain's last picture becomes next frame's `history`, once every pass has read // this frame's. - let before_last = &self.plan[..self.plan.len() - 1]; + let before_last = &self.plan[..self.plan.len().saturating_sub(1)]; if let Some(Writes::Chain(slot)) = before_last .iter() .rfind(|p| matches!(p.writes, Writes::Chain(_))) @@ -1080,6 +1447,11 @@ fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::T }) } +/// Vanilla's gamma colour `c` in linear light (0.2's decode; negatives and NaN made 0). +fn decoded(c: [f32; 3]) -> [f32; 3] { + c.map(|v| if v > 0.0 { v.powf(2.2) } else { 0.0 }) +} + /// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains /// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`). fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 { @@ -1342,7 +1714,14 @@ mod tests { }; let hud = crate::hud::HudBatch::new([4, 4]); let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud); - let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None); + let mut native = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ); frame.pack.still_ticks = Some(47_811); frame.pack.eye_light = [0, 15]; frame.pack.rain = 1.0; @@ -1374,6 +1753,152 @@ mod tests { assert!(!native.take_history_reset()); } + /// After a reset, the same frames give the same inputs, to the bit, as a pack just made, + /// however the frames before moved the eased values, wetness and the frame count. + #[test] + fn a_reset_frame_gives_a_fresh_pack_s_inputs() { + use crate::gfx::pack_uniforms::FrameTiming; + let Some((device, _)) = device() else { + return; + }; + let hud = crate::hud::HudBatch::new([4, 4]); + let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud); + let new = || { + NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ) + }; + let (mut used, mut fresh) = (new(), new()); + frame.pack.rain = 1.0; + frame.pack.eye_light = [12, 3]; + frame.pack.eye_in = 1; + for n in 1..=5 { + frame.pack.frame_timing = Some(FrameTiming::nth(n, 700.0)); + used.next_inputs(&frame, [4, 4]); + } + used.reset_history(); + frame.pack.eye_light = [2, 15]; + frame.pack.eye_in = 0; + for n in 1..=3 { + frame.pack.frame_timing = Some(FrameTiming::nth(n, 50.0)); + let inputs = [&mut used, &mut fresh].map(|f| f.next_inputs(&frame, [4, 4])); + assert_eq!( + bytemuck::bytes_of(&inputs[0]), + bytemuck::bytes_of(&inputs[1]), + "frame {n}" + ); + assert_eq!(used.take_history_reset(), fresh.take_history_reset()); + } + } + + /// After a reset a pass reads its pack's own targets blank, as in a pack just made: here + /// `fb`, which a pass reads before the pass that writes it, so it holds last frame's. + #[test] + fn a_reset_post_reads_its_own_targets_blank() { + let Some((device, queue)) = device() else { + return; + }; + let manifest = "schema = 1 +id = \"t\" +name = \"T\" +version = \"1\" +api = \"0.1\" + [[post]] +file = \"a.wgsl\" +[[post]] +file = \"b.wgsl\" +output = \"fb\" + [[post]] +file = \"c.wgsl\" +"; + let a = "fn post(input: PostInput) -> vec4 { return vec4(textureLoad(fb, vec2(input.pixel), 0).rgb + 0.25, 1.0); } +"; + let pass = "fn post(input: PostInput) -> vec4 { return input.color; } +"; + let files = [ + (MANIFEST, manifest), + ("a.wgsl", a), + ("b.wgsl", pass), + ("c.wgsl", pass), + ] + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .into(); + let report = check(&Pack::from_files(files).unwrap()).unwrap(); + let format = wgpu::TextureFormat::Rgba8Unorm; + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ); + let mut post = NativePost::new( + &device, + report.stages, + &report.manifest, + &[], + format, + &frame, + None, + ) + .unwrap(); + let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let color = texture(&device, format, usage).create_view(&Default::default()); + let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage) + .create_view(&Default::default()); + let screen = texture( + &device, + format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + ); + let view = screen.create_view(&Default::default()); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let red = |post: &mut NativePost| { + let mut encoder = device.create_command_encoder(&Default::default()); + let world = (&color, &depth, &frame.surface_view); + post.run(&device, &mut encoder, world, &view, [4, 4], false, None); + encoder.copy_texture_to_buffer( + screen.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT), + rows_per_image: Some(4), + }, + }, + screen.size(), + ); + queue.submit([encoder.finish()]); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let red = buffer.slice(..).get_mapped_range().unwrap()[0]; + buffer.unmap(); + red + }; + let near = |got: u8, want: u8| got.abs_diff(want) <= 1; + for want in [64, 128, 191] { + let got = red(&mut post); + assert!(near(got, want), "{got} before the reset, {want} expected"); + } + post.reset_history(); + let got = red(&mut post); + assert!(near(got, 64), "{got} after the reset: fb kept last frame's"); + } + fn device() -> Option<(wgpu::Device, wgpu::Queue)> { let instance = crate::gfx::test_gpu::instance(); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; @@ -1418,7 +1943,14 @@ mod tests { .into(); let report = check(&Pack::from_files(files).unwrap()).unwrap(); let format = wgpu::TextureFormat::Rgba8Unorm; - let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None); + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ); queue.write_buffer( &frame.buffer, 0, @@ -1431,6 +1963,7 @@ mod tests { &[("gain".into(), 1.0)], format, &frame, + None, ) .unwrap(); assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]); @@ -1538,7 +2071,14 @@ file = \"b.wgsl\" .into(); let report = check(&Pack::from_files(files).unwrap()).unwrap(); let format = wgpu::TextureFormat::Rgba8Unorm; - let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None); + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ); let mut post = NativePost::new( &device, report.stages, @@ -1546,6 +2086,7 @@ file = \"b.wgsl\" &[], format, &frame, + None, ) .unwrap(); let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; @@ -1650,7 +2191,14 @@ file = \"b.wgsl\" .into(); let report = check(&Pack::from_files(files).unwrap()).unwrap(); let format = wgpu::TextureFormat::Rgba8Unorm; - let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None); + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_1, + ); let mut post = NativePost::new( &device, report.stages, @@ -1658,6 +2206,7 @@ file = \"b.wgsl\" &[], format, &frame, + None, ) .unwrap(); let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; @@ -1929,4 +2478,126 @@ file = \"b.wgsl\" ); } } + + /// Shader API 0.2: the world decoded, through a chain that passes it on or none, and + /// Horizon's tonemap or the pack's identity one, comes back encoded as it went in (within + /// an 8-bit step), at alpha 1. + #[test] + fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() { + assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)")); + let Some((device, queue)) = device() else { + return; + }; + let post = "[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n"; + let curve = "[tonemap]\nfile = \"t.wgsl\"\n"; + // A pack needs a stage: the first has only a sky, and no chain or tonemap. + let sky = "[sky]\nfile = \"s.wgsl\"\n"; + for extra in [ + sky.into(), + post.into(), + curve.into(), + format!("{post}{curve}"), + ] { + let manifest = format!( + "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n{extra}" + ); + let pass = "fn post(input: PostInput) -> vec4 { return input.color; }\n"; + let identity = "fn tonemap(input: TonemapInput) -> vec3 { return input.color; }\n"; + let mut files = vec![(MANIFEST, manifest.as_str())]; + if extra.contains("[[post]]") { + files.extend([("a.wgsl", pass), ("b.wgsl", pass)]); + } + if extra.contains("[tonemap]") { + files.push(("t.wgsl", identity)); + } + if extra.contains("[sky]") { + files.push(( + "s.wgsl", + "fn sky(input: SkyInput) -> vec4 { return input.color; }\n", + )); + } + let files = files + .into_iter() + .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec())) + .collect(); + let report = check(&Pack::from_files(files).unwrap()).unwrap(); + let (passes, others): (Vec<_>, Vec<_>) = report + .stages + .into_iter() + .partition(|s| s.kind == StageKind::Post); + let format = wgpu::TextureFormat::Rgba8Unorm; + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + Api::V0_2, + ); + let inputs = FrameInputs { + headroom: 1.0, + ..Default::default() + }; + queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs)); + let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap); + let mut post = NativePost::new( + &device, + passes, + &report.manifest, + &[], + format, + &frame, + tonemap, + ) + .unwrap(); + assert_eq!(post.labels().last() == Some(&"t.wgsl"), tonemap.is_some()); + let usage = + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let color = texture(&device, format, usage).create_view(&Default::default()); + let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage) + .create_view(&Default::default()); + let screen = texture( + &device, + format, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + ); + let mut encoder = device.create_command_encoder(&Default::default()); + let clear = wgpu::Color { + r: 0.8, + g: 0.4, + b: 0.2, + a: 0.5, + }; + drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + })); + let view = screen.create_view(&Default::default()); + let world = (&color, &depth, &frame.surface_view); + post.run(&device, &mut encoder, world, &view, [4, 4], true, None); + let buffer = crate::gfx::copy_frame(&device, &mut encoder, &screen, [4, 4]); + queue.submit([encoder.finish()]); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec(); + let want = [204, 102, 51, 255]; + assert!( + pixel + .iter() + .zip(want) + .all(|(&got, want)| got.abs_diff(want) <= 1), + "{extra}: {pixel:?}" + ); + } + } } diff --git a/crates/horizon-client-render/src/gfx/native_shadow.rs b/crates/horizon-client-render/src/gfx/native_shadow.rs index c9f50bbbc1..ee0908b093 100644 --- a/crates/horizon-client-render/src/gfx/native_shadow.rs +++ b/crates/horizon-client-render/src/gfx/native_shadow.rs @@ -6,7 +6,7 @@ //! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the //! prelude's `shadow_position` undoes. -use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP}; +use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP}; use horizon_shaders::Shadow; use super::native_material::TerrainShape; @@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) { @fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4 { let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu); if color.a < 0.01 { discard; } - return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u)); + return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u)); } "; @@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> { pub normals: &'a wgpu::TextureView, } -/// `shadow_color`'s format. -const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band. +fn shadow_color(api: Api) -> wgpu::TextureFormat { + match api { + Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm, + Api::V0_2 => super::native_post::INTERMEDIATE, + } +} /// The shader pack's shadow camera for `settings`: orthographic over `distance` each side, /// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its @@ -218,7 +223,7 @@ pub struct ShadowMap { } impl ShadowMap { - pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self { + pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self { let size = settings.map_or(1, |s| s.resolution); let texture = |label, format, usage| { device.create_texture(&wgpu::TextureDescriptor { @@ -250,7 +255,7 @@ impl ShadowMap { ); let color = texture( "Native shader pack shadow colour", - SHADOW_COLOR, + shadow_color(api), wgpu::TextureUsages::empty(), ); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -474,6 +479,7 @@ fn shader_source( vertex: Option<&str>, materials: &[(u32, u32)], grid: Option<&str>, + api: Api, ) -> String { let table = super::native_material::layer_table("horizon_sprite_material", materials); let (head, displace) = match (grid, vertex) { @@ -504,11 +510,12 @@ fn shader_source( ), }; format!( - "{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}", + "{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}", prefixed( section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"), PREFIX - ) + ), + super::native_post::line_helpers(api) ) } @@ -527,6 +534,7 @@ impl NativeShadow { vertex.map(|s| s.source.as_str()), shape.sprite_materials, grid.as_ref().map(|g| g.material.source.as_str()), + frame.api, ); let module = naga::front::wgsl::parse_str(&text) .map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?; @@ -613,7 +621,7 @@ impl NativeShadow { attributes: &instance_attributes, }; let color_target = [Some(wgpu::ColorTargetState { - format: SHADOW_COLOR, + format: shadow_color(frame.api), blend: None, write_mask: wgpu::ColorWrites::ALL, })]; @@ -731,8 +739,9 @@ mod tests { #[test] fn the_shadow_shader_validates() { - for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] { - let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None); + let lines = [Api::V0_1, Api::V0_2]; + for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) { + let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api); let module = naga::front::wgsl::parse_str(&text).unwrap(); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), @@ -767,6 +776,7 @@ mod tests { None, &materials, Some(&stage.source), + Api::V0_1, ); let module = naga::front::wgsl::parse_str(&text) .unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text))); diff --git a/crates/horizon-client-render/src/gfx/native_sky.rs b/crates/horizon-client-render/src/gfx/native_sky.rs index 550775727f..66d1bb92af 100644 --- a/crates/horizon-client-render/src/gfx/native_sky.rs +++ b/crates/horizon-client-render/src/gfx/native_sky.rs @@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_"; const PACK_ENTRY: &str = " @fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4 { let d = horizon_sky_view_ray(in.ndc); - return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0); + let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0); + return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0); } "; @@ -38,10 +39,11 @@ fn vanilla_sky() -> String { } /// The sky shader for the pack's sky stage `source`. -fn shader_source(source: &str) -> String { +fn shader_source(source: &str, api: horizon_shader_api::Api) -> String { format!( - "{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}", - vanilla_sky() + "{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}", + vanilla_sky(), + super::native_post::line_helpers(api) ) } @@ -99,7 +101,7 @@ fn build( frame: &NativeFrame, stage: &CheckedStage, ) -> Result<(naga::Module, wgpu::PipelineLayout), String> { - let text = shader_source(&stage.source); + let text = shader_source(&stage.source, frame.api); let module = naga::front::wgsl::parse_str(&text) .map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?; let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default(); @@ -195,7 +197,7 @@ mod tests { // A pack may use the renderer's names for its own things. let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\ fn sky(input: SkyInput) -> vec4 { return input.color * gain * view_ray(); }\n"; - let text = shader_source(&stage(own).source); + let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1); let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| { panic!("{}", e.emit_to_string(&text)); }); @@ -252,7 +254,14 @@ mod tests { resource: globals_buffer.as_entire_binding(), }], }); - let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None); + let frame = NativeFrame::new( + &device, + None, + Vec::new(), + Default::default(), + None, + horizon_shader_api::Api::V0_1, + ); queue.write_buffer( &frame.buffer, 0, diff --git a/crates/horizon-client-render/src/gfx/pack_center_depth.rs b/crates/horizon-client-render/src/gfx/pack_center_depth.rs index f113f75754..31c3e5bf7f 100644 --- a/crates/horizon-client-render/src/gfx/pack_center_depth.rs +++ b/crates/horizon-client-render/src/gfx/pack_center_depth.rs @@ -217,6 +217,41 @@ impl CenterDepth { }) } + /// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as + /// the next frame reads it: an error unless exactly that one sample was read back, never + /// an older one. + pub fn take_completed(&mut self, device: &wgpu::Device) -> Result { + device + .poll(wgpu::PollType::Poll) + .map_err(|e| e.to_string())?; + let state = |s: &Slot| s.state.load(Ordering::Acquire); + let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED); + let (Some(slot), None) = (mapped.next(), mapped.next()) else { + return Err("the frame's centre depth wasn't read back alone".into()); + }; + if self + .slots + .iter() + .any(|s| !matches!(state(s), FREE | MAPPED)) + { + return Err("the frame's centre depth wasn't read back alone".into()); + } + let depth = slot + .buffer + .slice(..4) + .get_mapped_range() + .map(|bytes| bytemuck::pod_read_unaligned::(&bytes[..4])) + .map_err(|e| e.to_string()); + slot.buffer.unmap(); + slot.state.store(FREE, Ordering::Release); + let depth = depth?; + if !depth.is_finite() { + return Err("the centre depth isn't finite".into()); + } + self.latest = Some(depth.clamp(0.0, 1.0)); + Ok(depth.clamp(0.0, 1.0)) + } + /// The newest depth read back so far, `None` before the first arrives. pub fn collect(&mut self, device: &wgpu::Device) -> Option { let _ = device.poll(wgpu::PollType::Poll); @@ -265,6 +300,76 @@ mod tests { }); } + /// Inline and on the submit thread: each finished frame's depth, then none left to + /// take; several outstanding samples are refused. + #[test] + #[ignore = "requires a host GPU"] + fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() { + let (device, raw) = test_device().expect("host GPU required"); + let depth = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 3, + height: 3, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = depth.create_view(&Default::default()); + for threaded in [false, true] { + let queue = super::super::submit::Queue::new(raw.clone(), threaded); + if threaded { + assert!(queue.threaded(), "deferred submit unavailable"); + } + let mut center = CenterDepth::new(&device); + assert!(center.take_completed(&device).is_err()); + for value in [0.125, 0.75, 0.25] { + let mut encoder = device.create_command_encoder(&Default::default()); + clear(&mut encoder, &view, value); + center.copy(&device, &mut encoder, &depth); + let after = center.submitted().expect("one copied sample"); + if threaded { + let job = super::super::submit::Job { + encoder, + surface: None, + after: Some(Box::new(after)), + }; + assert!(queue.submit_later(job).is_ok()); + queue.wait(); + } else { + queue.submit([encoder.finish()]); + after(); + } + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(30)), + }) + .unwrap(); + assert_eq!(center.take_completed(&device).unwrap(), value); + assert_eq!(center.collect(&device), Some(value)); + assert!( + center.take_completed(&device).is_err(), + "no stale latest fallback" + ); + } + // Several outstanding copies are ambiguous, even after they all map. + let mut encoder = device.create_command_encoder(&Default::default()); + for _ in 0..SLOTS { + center.copy(&device, &mut encoder, &depth); + } + queue.submit([encoder.finish()]); + center.submitted().unwrap()(); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!(center.take_completed(&device).is_err()); + } + } + /// Frames that go to the submit thread start their readback there: the centre depth /// follows the frames instead of keeping the first ones' depth. #[test] diff --git a/crates/horizon-client-render/src/gfx/pack_entities.rs b/crates/horizon-client-render/src/gfx/pack_entities.rs index 5f8dea9f38..1084c7adb7 100644 --- a/crates/horizon-client-render/src/gfx/pack_entities.rs +++ b/crates/horizon-client-render/src/gfx/pack_entities.rs @@ -454,9 +454,31 @@ impl PackEntities { let first = spans.first().map_or(entities.len(), |s| s.start as usize); extend_without_portals(rest, &entities[..first.min(entities.len())]); let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new()); + let mut without_fluids = Vec::new(); for (span, range) in runs(entities.len(), spans) { let run_start = range.start as u32; let whole = &entities[range]; + // A native fluid carries sprite-array UVs, not shared-atlas UVs. + // Keep it in its own material even when a shader pack draws the + // model in the same block-entity run. Do not corrupt either UV space. + let whole: &[Vertex] = if whole + .chunks(4) + .any(|q| super::is_native_terrain(q[0].layer)) + { + without_fluids.clear(); + for quad in whole.chunks(4) { + if super::is_native_terrain(quad[0].layer) { + if !span.hidden { + rest.extend_from_slice(quad); + } + } else { + without_fluids.extend_from_slice(quad); + } + } + &without_fluids + } else { + whole + }; // A block entity's run can hold End portal faces and beams beside plain parts (an End // gateway: its cube and its beam): each goes to its own program. // Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4 @@ -1141,6 +1163,83 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range