Merge pull request #1561 from ludovic111/fix/remove-shader-packs
This commit is contained in:
@@ -43,7 +43,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -126,7 +125,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
Generated
+7
-398
@@ -178,15 +178,6 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
|
||||
|
||||
[[package]]
|
||||
name = "ascii-canvas"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
|
||||
dependencies = [
|
||||
"term",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ash"
|
||||
version = "0.38.0+1.3.281"
|
||||
@@ -214,30 +205,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -283,16 +259,6 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -484,15 +450,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud"
|
||||
version = "0.1.0"
|
||||
@@ -797,41 +754,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -849,29 +771,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -958,15 +857,6 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "ena"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -1230,58 +1120,6 @@ version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
|
||||
dependencies = [
|
||||
"glsl-lang-lexer",
|
||||
"glsl-lang-types",
|
||||
"lalrpop",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"once_cell",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-lexer"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
|
||||
dependencies = [
|
||||
"glsl-lang-pp",
|
||||
"glsl-lang-types",
|
||||
"lalrpop-util",
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-pp"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"derive_more",
|
||||
"lang-util",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glsl-lang-types"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
|
||||
dependencies = [
|
||||
"lang-util",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
@@ -1428,7 +1266,6 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaderpack",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1443,7 +1280,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"toml",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
@@ -1594,16 +1430,6 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaderpack"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"glsl-lang",
|
||||
"naga",
|
||||
"pp-rs",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
@@ -1755,12 +1581,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1943,73 +1763,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
|
||||
dependencies = [
|
||||
"ascii-canvas",
|
||||
"bit-set 0.8.0",
|
||||
"ena",
|
||||
"itertools",
|
||||
"lalrpop-util",
|
||||
"petgraph 0.7.1",
|
||||
"pico-args",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"sha3",
|
||||
"string_cache",
|
||||
"term",
|
||||
"unicode-xid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop-util"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"lalrpop-util",
|
||||
"lang-util-derive",
|
||||
"line-span",
|
||||
"smol_str 0.3.6",
|
||||
"text-size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lang-util-derive"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2066,12 +1819,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "line-span"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2183,7 +1930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2196,7 +1943,6 @@ dependencies = [
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2245,12 +1991,6 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -2750,16 +2490,6 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
@@ -2771,31 +2501,6 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pico-args"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2914,21 +2619,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pp-rs"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3022,15 +2712,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3369,16 +3050,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3407,12 +3078,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -3462,16 +3127,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -3521,37 +3176,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -3668,15 +3292,6 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "term"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3686,12 +3301,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-size"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3852,7 +3461,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph 0.8.3",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4573,8 +4182,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -4624,7 +4233,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -5122,7 +4731,7 @@ dependencies = [
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str 0.2.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
"wasm-bindgen",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -45,7 +45,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
@@ -57,7 +57,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
|
||||
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
|
||||
|
||||
@@ -29,13 +29,9 @@ png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
|
||||
toml = "1.1"
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
# Java (OptiFine/Iris) shader packs, translated to WGSL.
|
||||
horizon-shaderpack = { path = "../horizon-shaderpack" }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
|
||||
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
@@ -1199,8 +1198,6 @@ struct Game<'a> {
|
||||
modifiers: winit::keyboard::ModifiersState,
|
||||
/// The window's fullscreen request and reported state.
|
||||
fullscreen: horizon_client::window::FullscreenState,
|
||||
/// The selected Horizon shader pack (`game/shader_packs.rs`).
|
||||
shader_packs: shader_packs::ShaderPacks,
|
||||
/// MouseHandler's accumulated look and wheel state.
|
||||
mouse: MouseLook,
|
||||
/// An agent moved the mouse (`mouse-move`) since the pointer was last
|
||||
@@ -7434,30 +7431,17 @@ impl Game<'_> {
|
||||
self.tick_player_model();
|
||||
Ok(())
|
||||
}
|
||||
/// What the loaded Java shader pack changes in this dimension's vanilla
|
||||
/// sky (`sunPathRotation`, `clouds`, `sun`, `moon`); `None` without one.
|
||||
fn java_sky(&self) -> Option<(f32, horizon_shaderpack::properties::SkyDirectives)> {
|
||||
self.shader_packs.java_sky(match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
})
|
||||
}
|
||||
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
|
||||
/// A Java pack's `sunPathRotation` tilts it about Z in between, and its
|
||||
/// `sun = false` / `moon = false` leave those out, as Iris does. Returns
|
||||
/// the sun and moon quads, and the stars' frame and brightness (the
|
||||
/// renderer keeps their geometry, built once).
|
||||
/// Returns the sun and moon quads, and the stars' frame and brightness
|
||||
/// (the renderer keeps their geometry, built once).
|
||||
fn celestial_geometry(
|
||||
&self,
|
||||
sky: &horizon_client::environment::Sky,
|
||||
) -> (Vec<Vertex>, Option<(glam::Mat4, f32)>) {
|
||||
let mut out = Vec::new();
|
||||
let atlas = &self.gui.atlas;
|
||||
let (tilt, keep) = self.java_sky().unwrap_or_default();
|
||||
let frame = |angle: f32| {
|
||||
glam::Mat4::from_rotation_y((-90.0f32).to_radians())
|
||||
* glam::Mat4::from_rotation_z(tilt.to_radians())
|
||||
* glam::Mat4::from_rotation_x(angle.to_radians())
|
||||
};
|
||||
let mut body = |name: &str, angle: f32, size: f32, color: u32| {
|
||||
@@ -7479,14 +7463,12 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
};
|
||||
if keep.sun {
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
"environment/celestial/sun",
|
||||
sky.sun_angle,
|
||||
30.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
let phases = [
|
||||
"full_moon",
|
||||
"waning_gibbous",
|
||||
@@ -7498,14 +7480,12 @@ impl Game<'_> {
|
||||
"waxing_gibbous",
|
||||
];
|
||||
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
if keep.moon {
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
}
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
20.0,
|
||||
0xFFFF_FFFF,
|
||||
);
|
||||
// SkyRenderer.renderStars: the static star buffer on the same frame.
|
||||
let stars =
|
||||
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
|
||||
@@ -9831,7 +9811,6 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
self.poll_shader_pack();
|
||||
let Some(size) = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -10044,18 +10023,6 @@ impl Game<'_> {
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
entities.extend(dragon);
|
||||
}
|
||||
// A Java pack's `clouds` replaces the cloud option (`renderClouds`:
|
||||
// "false", "fast", "true"), as Iris overrides `getCloudsType`.
|
||||
let clouds_mode = {
|
||||
use horizon_shaderpack::properties::CloudSetting;
|
||||
match self.java_sky().and_then(|(_, sky)| sky.clouds) {
|
||||
Some(CloudSetting::Off) => "false",
|
||||
Some(CloudSetting::Fast) => "fast",
|
||||
Some(CloudSetting::Fancy) => "true",
|
||||
None => self.settings.clouds.as_str(),
|
||||
}
|
||||
.to_owned()
|
||||
};
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
@@ -10099,7 +10066,6 @@ impl Game<'_> {
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
shader: self.shader_inputs(camera, alpha as f32),
|
||||
nether_lighting: self.dim == Dim::Nether,
|
||||
camera,
|
||||
yaw,
|
||||
@@ -10147,7 +10113,7 @@ impl Game<'_> {
|
||||
hud: &hud,
|
||||
clouds: (!title
|
||||
&& self.dim == Dim::Overworld
|
||||
&& horizon_client::clouds::visible(&clouds_mode, sky.cloud_color[3]))
|
||||
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
|
||||
.then(|| self.gui.atlas.sprites.get("horizon/white"))
|
||||
.flatten()
|
||||
.map(|white| horizon_client::clouds::Input {
|
||||
@@ -10157,7 +10123,7 @@ impl Game<'_> {
|
||||
time: self.world.game_time,
|
||||
partial_tick: alpha,
|
||||
range: self.settings.cloud_range,
|
||||
fast: clouds_mode == "fast",
|
||||
fast: self.settings.clouds == "fast",
|
||||
color: sky.cloud_color,
|
||||
}),
|
||||
entities: &entities,
|
||||
@@ -10875,9 +10841,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::CloseRequested => {
|
||||
self.pending_save.as_mut().unwrap().quit = true;
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
self.drop_shader_pack(&path);
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.focused = focused;
|
||||
if !focused {
|
||||
@@ -11760,7 +11723,6 @@ impl<'a> Game<'a> {
|
||||
focused: true,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
fullscreen: horizon_client::window::FullscreenState::new(false),
|
||||
shader_packs: Default::default(),
|
||||
mouse: MouseLook::default(),
|
||||
agent_pointer: false,
|
||||
smooth_camera: false,
|
||||
@@ -12719,7 +12681,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
menu_blur: None,
|
||||
shader: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -589,7 +589,6 @@ impl Game<'_> {
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
"agent" => Page::Agent,
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
other => return Err(format!("unknown options page {other}").into()),
|
||||
};
|
||||
if self.screen == Screen::Playing && self.title.is_none() {
|
||||
|
||||
@@ -593,7 +593,6 @@ impl Game<'_> {
|
||||
DebugAction::ReloadResourcePacks => {
|
||||
self.debug_translated("debug.reload_resourcepacks.message");
|
||||
self.reload_packs();
|
||||
self.load_shader_pack();
|
||||
true
|
||||
}
|
||||
DebugAction::CopyLocation if player => {
|
||||
|
||||
@@ -200,7 +200,7 @@ impl Game<'_> {
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the Shader Packs page opens its folder.
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_folder(std::path::Path::new(url));
|
||||
|
||||
@@ -237,15 +237,7 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
Event::ReloadShaderPack => {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -294,14 +286,6 @@ impl Game<'_> {
|
||||
font.reset_cache();
|
||||
self.ui.chat.rescale(&self.gui.atlas);
|
||||
}
|
||||
// A new renderer (resource reload, first window) takes the loaded pack.
|
||||
let stale = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
|
||||
if stale && let Err(e) = self.apply_shader_pack() {
|
||||
log::warn!("Horizon: shader pack: {e}");
|
||||
}
|
||||
}
|
||||
/// `Window.updateFullscreenState`: a fullscreen change made by the window
|
||||
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
|
||||
|
||||
@@ -104,45 +104,6 @@ impl Game<'_> {
|
||||
},
|
||||
)
|
||||
}
|
||||
/// What a shader pack's `Frame` uniform reads of the game this frame.
|
||||
pub(super) fn shader_inputs(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
partial_tick: f32,
|
||||
) -> horizon_client::gfx::ShaderInputs {
|
||||
let (rain, thunder) = if self.dim == Dim::Overworld {
|
||||
self.world.weather.levels(self.clock.fraction() as f32)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let at = [camera.x, camera.y, camera.z];
|
||||
let day = self.world.day_time;
|
||||
let has_sky_light = self.dim == Dim::Overworld;
|
||||
// CommonUniforms.getEyeBrightness: the camera entity's eye block.
|
||||
let eye = self.eye_position().unwrap_or(at).map(|v| v.floor() as i32);
|
||||
let light = self.world.light(eye[0], eye[1], eye[2]);
|
||||
let eye_light = [light & 15, if has_sky_light { light >> 4 } else { 0 }];
|
||||
horizon_client::gfx::ShaderInputs {
|
||||
rain,
|
||||
thunder,
|
||||
submerged: match fog_type(&self.world, self.res.facts(), at, self.forward()) {
|
||||
FogType::Atmospheric => 0,
|
||||
FogType::Water => 1,
|
||||
FogType::Lava => 2,
|
||||
FogType::PowderSnow => 3,
|
||||
},
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
},
|
||||
has_sky_light,
|
||||
moon_phase: day.div_euclid(24_000).rem_euclid(8) as u8,
|
||||
day_fraction: (day.rem_euclid(24_000) as f32 + partial_tick) / 24_000.0,
|
||||
partial_tick,
|
||||
eye_light,
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
//! The selected Horizon shader pack (`shaderPack` in `horizon-graphics.txt`,
|
||||
//! docs/shader-packs.md): loaded from `shaderpacks/`, given to the renderer,
|
||||
//! and loaded again when the selection changes, on F3 + T, or when its files
|
||||
//! change (checked twice a second). A pack that fails leaves the last good
|
||||
//! one (or the vanilla picture) on and says why in chat.
|
||||
use super::Game;
|
||||
use horizon_client::shader_pack::{self, ShaderPack, Stamp};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shaderpack::properties::SkyDirectives;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ShaderPacks {
|
||||
/// The pack the renderer runs.
|
||||
pack: Option<ShaderPack>,
|
||||
/// The selection and its files at the last load, success or not.
|
||||
selected: String,
|
||||
stamp: Stamp,
|
||||
checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ShaderPacks {
|
||||
/// The loaded pack's folder name.
|
||||
pub(super) fn id(&self) -> Option<&str> {
|
||||
self.pack.as_ref().map(|p| p.id.as_str())
|
||||
}
|
||||
/// What a loaded Java pack changes in the vanilla sky of `dimension`
|
||||
/// (0 overworld, -1 the Nether, 1 the End): its `sunPathRotation`, and
|
||||
/// whether it keeps the clouds, sun and moon (Iris's sky mixins).
|
||||
pub(super) fn java_sky(&self, dimension: i8) -> Option<(f32, SkyDirectives)> {
|
||||
let java = self.pack.as_ref()?.java.as_ref()?;
|
||||
let folder = &java.folders[java.folder_for(dimension)?];
|
||||
Some((folder.directives.sun_path_rotation, java.sky))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dir() -> PathBuf {
|
||||
shader_pack::folder()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Load the selected pack and give it to the renderer.
|
||||
pub(super) fn load_shader_pack(&mut self) {
|
||||
let id = self.settings.horizon.shader_pack.clone();
|
||||
let dir = dir();
|
||||
let state = &mut self.shader_packs;
|
||||
state.selected = id.clone();
|
||||
state.checked = Some(Instant::now());
|
||||
let pack = if id.is_empty() {
|
||||
state.stamp = Stamp::default();
|
||||
None
|
||||
} else {
|
||||
state.stamp = Stamp::of(&dir.join(&id));
|
||||
// Java packs are told which GPU they run on.
|
||||
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
|
||||
let name = r.info.name.as_str();
|
||||
(
|
||||
horizon_client::java_pack::vendor_name(r.info.vendor, name),
|
||||
name,
|
||||
)
|
||||
});
|
||||
let values = shader_pack::load_values(&dir, &id);
|
||||
match ShaderPack::load_any(&dir.join(&id), &values, vendor, renderer) {
|
||||
Ok(pack) => Some(pack),
|
||||
Err(e) => return self.shader_pack_error(&id, &e),
|
||||
}
|
||||
};
|
||||
if let Some(r) = self.renderer.as_mut()
|
||||
&& let Err(e) = r.set_shader_pack(pack.as_ref(), &self.pack.render.sprite_layers)
|
||||
{
|
||||
return self.shader_pack_error(&id, &e.to_string());
|
||||
}
|
||||
self.shader_packs.pack = pack;
|
||||
self.ui.options.shader_pack_error = None;
|
||||
}
|
||||
/// Give the loaded pack to the renderer (also after it is rebuilt).
|
||||
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
|
||||
match self.renderer.as_mut() {
|
||||
Some(r) => r.set_shader_pack(
|
||||
self.shader_packs.pack.as_ref(),
|
||||
&self.pack.render.sprite_layers,
|
||||
),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
/// Every frame: a new selection or edited pack files load it again.
|
||||
pub(super) fn poll_shader_pack(&mut self) {
|
||||
let state = &mut self.shader_packs;
|
||||
if self.settings.horizon.shader_pack != state.selected {
|
||||
return self.load_shader_pack();
|
||||
}
|
||||
if state.selected.is_empty()
|
||||
|| state
|
||||
.checked
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_millis(500))
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.checked = Some(Instant::now());
|
||||
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
|
||||
self.load_shader_pack();
|
||||
}
|
||||
}
|
||||
/// A file dropped on the window: a shader pack while the Shader Packs
|
||||
/// page shows (`OptionsUi::drop_shader_pack`).
|
||||
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
|
||||
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
|
||||
Some(Ok(())) => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.load_shader_pack();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", path.display());
|
||||
self.system_message(Text::literal(format!("Shader pack: {e}")));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
fn shader_pack_error(&mut self, id: &str, error: &str) {
|
||||
log::warn!("Horizon: shader pack {id}: {error}");
|
||||
// The Shader Packs page says why on the pack's row: the first line,
|
||||
// since translator errors go on with a drawn source excerpt.
|
||||
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
|
||||
self.ui.options.shader_pack_error =
|
||||
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
|
||||
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
|
||||
}
|
||||
}
|
||||
@@ -367,11 +367,6 @@ impl Loading {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::OpenShaderPackFolder => {
|
||||
let dir = horizon_client::shader_pack::folder();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
packs::open_folder(&dir);
|
||||
}
|
||||
Event::Credits => {
|
||||
if let Some(gui) = &self.gui {
|
||||
let name = self.options.as_ref().map_or_else(
|
||||
@@ -387,9 +382,6 @@ impl Loading {
|
||||
self.menu.win = Some((screen, Instant::now()));
|
||||
}
|
||||
}
|
||||
Event::ReloadShaderPack => {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
@@ -455,17 +447,6 @@ impl Loading {
|
||||
}
|
||||
self.apply_menu_settings();
|
||||
}
|
||||
WindowEvent::DroppedFile(path) => {
|
||||
match self
|
||||
.menu
|
||||
.options
|
||||
.drop_shader_pack(&mut self.settings, &path)
|
||||
{
|
||||
Some(Ok(())) => self.save_menu_settings(),
|
||||
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.draw(),
|
||||
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
|
||||
WindowEvent::Focused(false) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,9 +51,6 @@ pub(super) struct SpriteAnimator {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The sprite layers (across the arrays) the last `animate` rewrote, at
|
||||
/// every mip level: a Java pack's atlas copies them.
|
||||
pub changed: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -208,7 +205,6 @@ impl SpriteAnimator {
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +216,6 @@ impl SpriteAnimator {
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
) {
|
||||
self.changed.clear();
|
||||
if self.collect(tick) == 0 {
|
||||
return;
|
||||
}
|
||||
@@ -256,7 +251,6 @@ impl SpriteAnimator {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
self.changed.push(sprite.animation.layer);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
|
||||
@@ -65,11 +65,8 @@ pub struct Arena {
|
||||
}
|
||||
|
||||
impl Arena {
|
||||
/// Vertices; also storage, which a Java shader pack's terrain programs
|
||||
/// read a quad's corners from (`gfx/java_terrain.rs`).
|
||||
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE;
|
||||
Self::with_usage(device, stride, capacity, usage)
|
||||
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
|
||||
}
|
||||
/// Sorted translucent indices; `storage` when compute passes read them.
|
||||
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
|
||||
@@ -148,10 +145,6 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// One past the last page index.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.pages.len() as u32
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
|
||||
@@ -108,14 +108,6 @@ fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
|
||||
see(in.test);
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
// fs_box_early against a Java shader pack's depth (`vs_box_gl`), in a pass
|
||||
// without colour targets.
|
||||
@fragment
|
||||
@early_depth_test(force)
|
||||
fn fs_box_early_depth(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
";
|
||||
|
||||
/// The test's depth texture binding (`InShader`), for the view it was made with.
|
||||
@@ -126,8 +118,6 @@ struct DepthGroup {
|
||||
|
||||
pub struct Cull {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// [`Self::test_gl`]'s: a depth-only pass in OpenGL's layout.
|
||||
gl_pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: Option<Group>,
|
||||
depth_test: DepthTest,
|
||||
@@ -271,76 +261,55 @@ impl Cull {
|
||||
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
|
||||
..Default::default()
|
||||
});
|
||||
// `gl`: a Java shader pack's depth (`test_gl`), in OpenGL's layout
|
||||
// (y flipped, and with it the faces' winding; 0 near), without a
|
||||
// colour target. In the shader (`InShader`): no colour target, and
|
||||
// the depth attached read only (a pass needs an attachment), its
|
||||
// test left to the shader.
|
||||
let pipeline = |label, gl: bool| {
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match (depth_test, gl) {
|
||||
(DepthTest::Early, false) => (
|
||||
"fs_box_early",
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
(DepthTest::Early, true) => (
|
||||
"fs_box_early_depth",
|
||||
&[][..],
|
||||
wgpu::CompareFunction::LessEqual,
|
||||
),
|
||||
(DepthTest::InShader, false) => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
(DepthTest::InShader, true) => {
|
||||
("fs_box_gl", &[][..], wgpu::CompareFunction::Always)
|
||||
}
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(if gl { "vs_box_gl" } else { "vs_box" }),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
front_face: if gl {
|
||||
wgpu::FrontFace::Cw
|
||||
} else {
|
||||
wgpu::FrontFace::Ccw
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
// The pass's depth test (reversed in the world pass, OpenGL's
|
||||
// in a Java pack's), without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
// In the shader (`InShader`): no colour target, and the depth
|
||||
// attached read only (a pass needs an attachment), its test left to
|
||||
// the shader.
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: color,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})];
|
||||
let (fragment_entry, targets, depth_compare) = match depth_test {
|
||||
DepthTest::Early => (
|
||||
"fs_box_early",
|
||||
&color_target[..],
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
),
|
||||
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
|
||||
};
|
||||
let gl_pipeline = pipeline("Section culling, OpenGL layout", true);
|
||||
let pipeline = pipeline("Section culling", false);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Section culling"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_box"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// The world pass's reversed depth test, without writes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(depth_compare),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(fragment_entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
gl_pipeline,
|
||||
layout,
|
||||
group: None,
|
||||
depth_test,
|
||||
@@ -586,51 +555,6 @@ impl Cull {
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// [`Self::test`] for a Java shader pack that draws the terrain
|
||||
/// (`gfx/java_stages.rs`): in a pass of its own against `depth`, the
|
||||
/// pack's depth after phase 1 in OpenGL's layout (y up, 0 near), attached
|
||||
/// read only, and also read by the shader where the test runs there
|
||||
/// (`InShader`).
|
||||
pub fn test_gl(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
let Some(Group { group, .. }) = &self.group else {
|
||||
return;
|
||||
};
|
||||
if self.test_data.is_empty() {
|
||||
return;
|
||||
}
|
||||
let depth_group = self.depth_layout.as_ref().map(|layout| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section culling depth, Java shader pack"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
})
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling, Java shader pack"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gl_pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
if let Some(depth) = &depth_group {
|
||||
pass.set_bind_group(1, depth, &[]);
|
||||
}
|
||||
pass.draw(0..36, 0..self.test_data.len() as u32);
|
||||
}
|
||||
|
||||
/// After the world passes: copy the results for reading back, when a
|
||||
/// readback buffer is free.
|
||||
pub fn end(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) {
|
||||
|
||||
@@ -52,10 +52,6 @@ fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
|
||||
// section fail the depth test against the final depth, which is only nearer.
|
||||
@vertex
|
||||
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
return box_vertex(v, t);
|
||||
}
|
||||
|
||||
fn box_vertex(v: u32, t: u32) -> Box {
|
||||
let origin = origins[tests[t].x].xyz;
|
||||
var out: Box;
|
||||
out.test = t;
|
||||
@@ -90,15 +86,6 @@ fn box_vertex(v: u32, t: u32) -> Box {
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same box in OpenGL's layout, for a Java shader pack's terrain depth
|
||||
// (gfx/java_stages.rs): y flipped, depth from 0 near to 1 far.
|
||||
@vertex
|
||||
fn vs_box_gl(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
|
||||
var out = box_vertex(v, t);
|
||||
out.position = vec4(out.position.x, -out.position.y, out.position.w - out.position.z, out.position.w);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The box is seen at this fragment: store the stamp, and give its phase 2
|
||||
// draws their instance.
|
||||
fn see(t: u32) {
|
||||
@@ -124,12 +111,3 @@ fn fs_box(in: Box) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
|
||||
// fs_box against a Java shader pack's depth in OpenGL's layout (`vs_box_gl`):
|
||||
// less or equal passing.
|
||||
@fragment
|
||||
fn fs_box_gl(in: Box) {
|
||||
if in.position.z <= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
|
||||
see(in.test);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,36 +640,26 @@ impl Flat {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
/// The bind group layout of the draws' group 1 (block slots, slot
|
||||
/// origins).
|
||||
pub fn draw_layout(&self) -> &wgpu::BindGroupLayout {
|
||||
&self.draw_layout
|
||||
}
|
||||
|
||||
/// Draws `calls` with a flat pipeline set and `vertices` giving each
|
||||
/// page's vertex buffer; `page` binds what else a page's draws need (a
|
||||
/// Java pack's terrain reads the page's vertices).
|
||||
/// page's vertex buffer.
|
||||
pub fn draw<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
calls: &[Call],
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for call in calls {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them; `page` as in
|
||||
/// [`Self::draw`].
|
||||
/// Draws phase 2's calls, as `flatten_phase2` left them.
|
||||
pub fn draw_phase2<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
|
||||
) {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
|
||||
@@ -677,7 +667,6 @@ impl Flat {
|
||||
.enumerate()
|
||||
{
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
page(pass, call.page);
|
||||
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,667 +0,0 @@
|
||||
//! Horizon's terrain drawn with a Java pack's `gbuffers_terrain` and
|
||||
//! `gbuffers_water` (docs/shader-packs.md). The pack's vertex program runs
|
||||
//! after a glue that rebuilds what Iris would feed it from Horizon's
|
||||
//! `TerrainVertex`: the camera-relative position, the quad's normal and
|
||||
//! tangent (from its four corners, read from the arena page as storage),
|
||||
//! the colour without vanilla's directional shade, the lightmap
|
||||
//! coordinates, the sprite's place in a block atlas laid out from Horizon's
|
||||
//! sprite arrays, and `mc_Entity` from the pack's `block.properties`, by
|
||||
//! sprite name.
|
||||
use horizon_shaderpack::translate::Geometry;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `TerrainVertex` in 32-bit words.
|
||||
const VERTEX_WORDS: u32 = 6;
|
||||
|
||||
/// The glue's declarations and `hz_vertex`; `HZ_ORIGIN_DECL` and
|
||||
/// `HZ_ORIGIN` are the variant's (instanced or flat draws).
|
||||
const GLUE: &str = r#"
|
||||
layout(location = 0) in vec3 hz_position;
|
||||
layout(location = 1) in uvec2 hz_uv;
|
||||
layout(location = 2) in uint hz_layer;
|
||||
layout(location = 3) in vec4 hz_color;
|
||||
layout(location = 4) in uvec2 hz_light;
|
||||
HZ_ORIGIN_DECL
|
||||
layout(std430, set = 3, binding = 0) readonly buffer HzVertices { uint hz_vertices[]; };
|
||||
// [0]: atlas columns, [1]: 1 in the Nether, then each sprite layer's block id.
|
||||
layout(std430, set = 3, binding = 1) readonly buffer HzTable { int hz_table[]; };
|
||||
vec4 hz_pos;
|
||||
vec3 hz_normal;
|
||||
vec4 hz_tangent;
|
||||
vec4 hz_tint;
|
||||
vec2 hz_atlas;
|
||||
vec2 hz_mid;
|
||||
vec2 hz_lightmap;
|
||||
float hz_block;
|
||||
vec3 hz_mid_block;
|
||||
vec3 hz_corner(uint v) {
|
||||
uint b = v * HZ_WORDSu;
|
||||
return vec3(uintBitsToFloat(hz_vertices[b]), uintBitsToFloat(hz_vertices[b + 1u]),
|
||||
uintBitsToFloat(hz_vertices[b + 2u]));
|
||||
}
|
||||
vec2 hz_corner_uv(uint v) {
|
||||
uint w = hz_vertices[v * HZ_WORDSu + 3u];
|
||||
return vec2(float(w & 65535u), float(w >> 16u));
|
||||
}
|
||||
void hz_vertex() {
|
||||
vec4 origin = HZ_ORIGIN;
|
||||
hz_pos = vec4(hz_position + origin.xyz, 1.0);
|
||||
// The quad's corners: quads are four vertices from a multiple of four.
|
||||
uint q = uint(gl_VertexIndex) & 4294967292u;
|
||||
vec3 p0 = hz_corner(q);
|
||||
vec3 e1 = hz_corner(q + 1u) - p0;
|
||||
vec3 e2 = hz_corner(q + 2u) - p0;
|
||||
vec3 n = cross(e1, e2);
|
||||
if (dot(n, n) < 1e-12) {
|
||||
n = cross(e2, hz_corner(q + 3u) - p0);
|
||||
}
|
||||
hz_normal = dot(n, n) > 0.0 ? normalize(n) : vec3(0.0, 1.0, 0.0);
|
||||
vec2 t0 = hz_corner_uv(q);
|
||||
vec2 d1 = hz_corner_uv(q + 1u) - t0;
|
||||
vec2 d2 = hz_corner_uv(q + 2u) - t0;
|
||||
float s = d1.x * d2.y - d2.x * d1.y < 0.0 ? -1.0 : 1.0;
|
||||
vec3 t = (e1 * d2.y - e2 * d1.y) * s;
|
||||
vec3 b = (e2 * d1.x - e1 * d2.x) * s;
|
||||
t = t - hz_normal * dot(hz_normal, t);
|
||||
t = dot(t, t) > 0.0 ? normalize(t) : vec3(1.0, 0.0, 0.0);
|
||||
hz_tangent = vec4(t, dot(cross(hz_normal, t), b) < 0.0 ? -1.0 : 1.0);
|
||||
// Iris turns vanilla's directional shade off: undo it on axis faces.
|
||||
vec3 a = abs(hz_normal);
|
||||
bool nether = hz_table[1] != 0;
|
||||
float shade = 1.0;
|
||||
if (a.y > 0.999) {
|
||||
shade = nether ? 0.9 : (hz_normal.y > 0.0 ? 1.0 : 0.5);
|
||||
} else if (a.z > 0.999) {
|
||||
shade = 0.8;
|
||||
} else if (a.x > 0.999) {
|
||||
shade = 0.6;
|
||||
}
|
||||
hz_tint = vec4(min(hz_color.rgb / shade, vec3(1.0)), hz_color.a);
|
||||
// The sprite's cell in the atlas, columns of square cells.
|
||||
int layer = int(hz_layer & 16383u);
|
||||
int cols = max(hz_table[0], 1);
|
||||
vec2 cell = vec2(float(layer % cols), float(layer / cols));
|
||||
vec2 uv = clamp(vec2(hz_uv) * origin.w, 0.0, 1.0);
|
||||
hz_atlas = (cell + uv) / float(cols);
|
||||
hz_mid = (cell + 0.5) / float(cols);
|
||||
// Vanilla's UV2: block light, then sky light, 0..240.
|
||||
hz_lightmap = vec2(float(hz_light.y), float(hz_light.x));
|
||||
hz_block = float(hz_table[2 + layer]);
|
||||
hz_mid_block = (floor(hz_position - hz_normal * 0.01) + 0.5 - hz_position) * 64.0;
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The glue of one draw path: instanced section draws (the origin is an
|
||||
/// instance attribute) or flat ones (`gfx/flat.rs`: from the block's slot).
|
||||
pub fn glue(flat: bool, alpha_test: Option<f32>) -> Geometry {
|
||||
let (decl, origin) = if flat {
|
||||
(
|
||||
"layout(std430, set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(std430, set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };",
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]",
|
||||
)
|
||||
} else {
|
||||
("layout(location = 5) in vec4 hz_origin;", "hz_origin")
|
||||
};
|
||||
let vertex_glue = GLUE
|
||||
.replace("HZ_ORIGIN_DECL", decl)
|
||||
.replace("HZ_ORIGIN", origin)
|
||||
.replace("HZ_WORDS", &VERTEX_WORDS.to_string());
|
||||
let lightmap = "vec4(hz_lightmap, 0.0, 1.0)";
|
||||
let attributes: BTreeMap<String, String> = [
|
||||
("iris_Vertex", "hz_pos"),
|
||||
("vaPosition", "hz_pos"),
|
||||
("iris_Normal", "vec4(hz_normal, 0.0)"),
|
||||
("vaNormal", "vec4(hz_normal, 0.0)"),
|
||||
("iris_Color", "hz_tint"),
|
||||
("vaColor", "hz_tint"),
|
||||
("iris_MultiTexCoord0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("vaUV0", "vec4(hz_atlas, 0.0, 1.0)"),
|
||||
("iris_MultiTexCoord1", lightmap),
|
||||
("iris_MultiTexCoord2", lightmap),
|
||||
("vaUV2", lightmap),
|
||||
// OverlayTexture.NO_OVERLAY.
|
||||
("vaUV1", "vec4(0.0, 10.0, 0.0, 0.0)"),
|
||||
("mc_Entity", "vec4(hz_block, -1.0, 0.0, 0.0)"),
|
||||
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
|
||||
("at_tangent", "hz_tangent"),
|
||||
("at_midBlock", "vec4(hz_mid_block, 0.0)"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.collect();
|
||||
Geometry {
|
||||
vertex_glue,
|
||||
attributes,
|
||||
alpha_test,
|
||||
}
|
||||
}
|
||||
|
||||
/// The glue's table: atlas columns, the Nether flag, then each sprite
|
||||
/// layer's block id from `block.properties` (-1 for blocks it doesn't
|
||||
/// list, as Iris).
|
||||
pub fn table(sprite_layers: &BTreeMap<String, u32>, block_ids: &BTreeMap<String, i32>) -> Vec<i32> {
|
||||
let layers = sprite_layers.values().max().map_or(0, |&m| m as usize + 1);
|
||||
let mut out = vec![-1; layers + 2];
|
||||
out[0] = atlas_columns(layers as u32) as i32;
|
||||
out[1] = 0;
|
||||
for (name, &layer) in sprite_layers {
|
||||
if let Some(id) = horizon_shaderpack::properties::sprite_block_id(name, block_ids) {
|
||||
out[layer as usize + 2] = id;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The atlas's columns (and rows) of sprite cells for `layers` sprites.
|
||||
pub fn atlas_columns(layers: u32) -> u32 {
|
||||
(f64::from(layers.max(1)).sqrt().ceil() as u32).max(1)
|
||||
}
|
||||
|
||||
/// Whether `device` renders to and filters the 16-bit normalized formats
|
||||
/// a Java pack's `RGBA16` targets become (`java_stages::target_format`).
|
||||
pub fn norm16_targets(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
use wgpu::TextureFormat as F;
|
||||
let wanted = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
|
||||
device.features().contains(wanted)
|
||||
&& [
|
||||
F::R16Unorm,
|
||||
F::Rg16Unorm,
|
||||
F::Rgba16Unorm,
|
||||
F::R16Snorm,
|
||||
F::Rg16Snorm,
|
||||
F::Rgba16Snorm,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|f| {
|
||||
let features = adapter.get_texture_format_features(f);
|
||||
features
|
||||
.allowed_usages
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
|
||||
&& features
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::FILTERABLE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The bind group layout of a page group: the page's vertices and the glue's
|
||||
/// table, read by the vertex stage.
|
||||
pub fn page_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack terrain page"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of Horizon's terrain draws a pack's program draws.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Layer {
|
||||
/// The solid sections: all of them, or with occlusion culling
|
||||
/// ([`JavaWorld::occlusion`]) the ones the last test saw (phase 1).
|
||||
Solid,
|
||||
/// With occlusion culling, the solid sections the test after phase 1
|
||||
/// found in sight (phase 2).
|
||||
Revealed,
|
||||
Translucent,
|
||||
/// The shadow map's sections ([`super::Renderer`]'s shadow list), solid
|
||||
/// then translucent.
|
||||
ShadowSolid,
|
||||
ShadowTranslucent,
|
||||
}
|
||||
|
||||
/// Draws Horizon's terrain with a pack's pipelines. Set by the renderer,
|
||||
/// which owns the sections.
|
||||
pub type DrawTerrain<'a> = dyn Fn(&mut wgpu::RenderPass<'_>, Layer, &TerrainPipelines<'_>) + 'a;
|
||||
|
||||
/// Occlusion culling's test between [`Layer::Solid`] and
|
||||
/// [`Layer::Revealed`], against the pack's depth (OpenGL's layout).
|
||||
pub type OcclusionTest<'a> = dyn Fn(&mut wgpu::CommandEncoder, &wgpu::TextureView) + 'a;
|
||||
|
||||
/// What the renderer binds to draw with a pack's geometry program: the
|
||||
/// pipeline of its draw path and the pack's group (group 2). Group 1 is the
|
||||
/// flat path's, group 3 the page's ([`page_layout`]). `flat` is `None` when
|
||||
/// the renderer has no flat draws.
|
||||
pub struct TerrainPipelines<'a> {
|
||||
pub instanced: &'a wgpu::RenderPipeline,
|
||||
pub flat: Option<&'a wgpu::RenderPipeline>,
|
||||
pub group: &'a wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// What a Java pack's geometry reads from the renderer each frame.
|
||||
pub struct JavaWorld<'a> {
|
||||
/// The block sprite arrays, [`super::SPRITE_SPLIT`]-layer each.
|
||||
pub sprites: &'a [wgpu::Texture],
|
||||
pub split: u32,
|
||||
/// Mipmap levels the terrain samples.
|
||||
pub levels: u32,
|
||||
/// The sprite layers and mip levels animation rewrote this frame.
|
||||
pub animated: &'a [(u32, u32)],
|
||||
pub lightmap: &'a wgpu::TextureView,
|
||||
pub draw: &'a DrawTerrain<'a>,
|
||||
/// Set when the frame culls solid sections by occlusion.
|
||||
pub occlusion: Option<&'a OcclusionTest<'a>>,
|
||||
}
|
||||
|
||||
const ATLAS: &str = r#"
|
||||
struct Atlas { cols: u32, layers: u32, split: u32, pad: u32 }
|
||||
@group(0) @binding(0) var<uniform> atlas: Atlas;
|
||||
@group(0) @binding(1) var s0: texture_2d_array<f32>;
|
||||
@group(0) @binding(2) var s1: texture_2d_array<f32>;
|
||||
@group(0) @binding(3) var s2: texture_2d_array<f32>;
|
||||
@group(0) @binding(4) var s3: texture_2d_array<f32>;
|
||||
@group(0) @binding(5) var s4: texture_2d_array<f32>;
|
||||
@group(0) @binding(6) var s5: texture_2d_array<f32>;
|
||||
@group(0) @binding(7) var s6: texture_2d_array<f32>;
|
||||
@group(0) @binding(8) var s7: texture_2d_array<f32>;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) p: vec4f,
|
||||
@location(0) @interpolate(flat) mip: u32,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mip: u32) -> Out {
|
||||
let uv = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return Out(vec4f(uv * 2.0 - 1.0, 0.0, 1.0), mip);
|
||||
}
|
||||
|
||||
// Each sprite layer into its cell, at mip level `mip`.
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4f {
|
||||
let cell = max(textureDimensions(s0, in.mip).x, 1u);
|
||||
let px = vec2u(in.p.xy);
|
||||
let c = px / cell;
|
||||
let layer = c.y * atlas.cols + c.x;
|
||||
if c.x >= atlas.cols || layer >= atlas.layers {
|
||||
return vec4f(0.0);
|
||||
}
|
||||
let at = px % cell;
|
||||
let l = i32(layer % atlas.split);
|
||||
let m = i32(in.mip);
|
||||
switch layer / atlas.split {
|
||||
case 0u: { return textureLoad(s0, at, l, m); }
|
||||
case 1u: { return textureLoad(s1, at, l, m); }
|
||||
case 2u: { return textureLoad(s2, at, l, m); }
|
||||
case 3u: { return textureLoad(s3, at, l, m); }
|
||||
case 4u: { return textureLoad(s4, at, l, m); }
|
||||
case 5u: { return textureLoad(s5, at, l, m); }
|
||||
case 6u: { return textureLoad(s6, at, l, m); }
|
||||
default: { return textureLoad(s7, at, l, m); }
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Horizon's block sprites as one 2D atlas, as Iris binds `gtexture`:
|
||||
/// square cells, [`atlas_columns`] to a row. Drawn from the sprite arrays
|
||||
/// once, then animated sprites are copied in as they change.
|
||||
pub struct Atlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
mips: Vec<wgpu::TextureView>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layers, sprite size, mip levels it was made for.
|
||||
key: (u32, u32, u32),
|
||||
/// The first sprite array it was drawn from (resource reloads make
|
||||
/// new ones).
|
||||
source: wgpu::Texture,
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Atlas {
|
||||
/// The atlas for `world`'s sprites; `None` without any.
|
||||
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, world: &JavaWorld) -> Option<Self> {
|
||||
let key = Self::key(world)?;
|
||||
let (layers, size, levels) = key;
|
||||
let cols = atlas_columns(layers);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: cols * size,
|
||||
height: cols * size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mips = (0..levels)
|
||||
.map(|level| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}];
|
||||
entries.extend((1..=8).map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
entries: &entries,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
source: wgpu::ShaderSource::Wgsl(ATLAS.into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
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(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[cols, layers, world.split.max(1), 0]),
|
||||
);
|
||||
Some(Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
mips,
|
||||
pipeline,
|
||||
layout,
|
||||
uniform,
|
||||
key,
|
||||
source: world.sprites[0].clone(),
|
||||
drawn: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(world: &JavaWorld) -> Option<(u32, u32, u32)> {
|
||||
let first = world.sprites.first()?;
|
||||
let layers = world
|
||||
.sprites
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let levels = (world.levels + 1).min(first.mip_level_count()).max(1);
|
||||
Some((layers, first.width(), levels))
|
||||
}
|
||||
|
||||
/// Whether it was made for `world`'s sprites.
|
||||
pub fn fits(&self, world: &JavaWorld) -> bool {
|
||||
Self::key(world) == Some(self.key) && world.sprites.first() == Some(&self.source)
|
||||
}
|
||||
|
||||
/// Bring it up to date: drawn the first time, then the sprites
|
||||
/// animation rewrote copied into their cells.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: &JavaWorld,
|
||||
) {
|
||||
if !self.drawn {
|
||||
self.draw(device, encoder, world);
|
||||
self.drawn = true;
|
||||
return;
|
||||
}
|
||||
let (layers, size, levels) = self.key;
|
||||
let cols = atlas_columns(layers);
|
||||
let split = world.split.max(1);
|
||||
for &(layer, level) in world.animated {
|
||||
let Some(array) = world.sprites.get((layer / split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
if level >= levels || layer >= layers {
|
||||
continue;
|
||||
}
|
||||
let edge = (size >> level).max(1);
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (layer % cols) * edge,
|
||||
y: (layer / cols) * edge,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
|
||||
/// Draw the sprites into the atlas, every mip level.
|
||||
fn draw(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: &JavaWorld) {
|
||||
let views: Vec<wgpu::TextureView> = (0..8)
|
||||
.map(|i| {
|
||||
let t = world.sprites.get(i).unwrap_or(&world.sprites[0]);
|
||||
t.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
mip_level_count: Some(self.key.2),
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
});
|
||||
for (mip, view) in self.mips.iter().enumerate() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Java shader pack block atlas"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, mip as u32..mip as u32 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `module` validates and translates for Metal, Vulkan and DirectX 12.
|
||||
fn backends(module: &naga::Module, label: &str) {
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(module)
|
||||
.unwrap_or_else(|e| panic!("{label}: {e:?}"));
|
||||
naga::back::msl::write_string(
|
||||
module,
|
||||
&info,
|
||||
// wgpu's Metal backend asks for MSL 2.x.
|
||||
&naga::back::msl::Options {
|
||||
lang_version: (2, 4),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{label} (MSL): {e}"));
|
||||
naga::back::spv::write_vec(module, &info, &Default::default(), None)
|
||||
.unwrap_or_else(|e| panic!("{label} (SPIR-V): {e}"));
|
||||
for entry in &module.entry_points {
|
||||
let mut hlsl = String::new();
|
||||
naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&naga::back::hlsl::Options::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((entry.stage, entry.name.clone())),
|
||||
},
|
||||
)
|
||||
.write(module, &info, None)
|
||||
.unwrap_or_else(|e| panic!("{label} {} (HLSL): {e}", entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terrain program reading every attribute the glue fills, run after
|
||||
/// each draw path's glue, and the atlas shader, on every backend.
|
||||
#[test]
|
||||
fn the_glue_feeds_a_terrain_program_on_every_backend() {
|
||||
let program = "#version 120
|
||||
attribute vec4 mc_Entity;
|
||||
attribute vec4 mc_midTexCoord;
|
||||
attribute vec4 at_tangent;
|
||||
attribute vec3 at_midBlock;
|
||||
varying vec4 color;
|
||||
varying vec2 uv;
|
||||
varying vec2 lm;
|
||||
varying vec3 normal;
|
||||
varying float id;
|
||||
void main() {
|
||||
vec4 p = gl_ModelViewMatrix * gl_Vertex;
|
||||
p.xyz += at_midBlock / 64.0 * 0.0 + at_tangent.xyz * 0.0;
|
||||
gl_Position = gl_ProjectionMatrix * p;
|
||||
color = gl_Color;
|
||||
uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy + mc_midTexCoord.xy * 0.0;
|
||||
lm = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;
|
||||
normal = gl_NormalMatrix * gl_Normal;
|
||||
id = mc_Entity.x;
|
||||
}
|
||||
";
|
||||
for flat in [false, true] {
|
||||
let mut link = horizon_shaderpack::translate::Link {
|
||||
set: 2,
|
||||
geometry: Some(glue(flat, Some(0.1))),
|
||||
..Default::default()
|
||||
};
|
||||
let t = horizon_shaderpack::translate::translate(
|
||||
horizon_shaderpack::Stage::Vertex,
|
||||
program,
|
||||
&mut link,
|
||||
&|l| format!("line {l}"),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("flat {flat}: {e}"));
|
||||
for wanted in ["hz_vertex();", "mc_Entity = vec4(vec4(hz_block"] {
|
||||
assert!(t.glsl.contains(wanted), "{wanted}\n{}", t.glsl);
|
||||
}
|
||||
// The glue's inputs are Horizon's vertex layout.
|
||||
let inputs = t.module.entry_points[0].function.arguments.len();
|
||||
assert_eq!(inputs, if flat { 6 } else { 7 }, "{}", t.wgsl);
|
||||
let reparsed = naga::front::wgsl::parse_str(&t.wgsl).unwrap();
|
||||
backends(&reparsed, &format!("terrain glue (flat {flat})"));
|
||||
}
|
||||
backends(&naga::front::wgsl::parse_str(ATLAS).unwrap(), "atlas");
|
||||
backends(
|
||||
&naga::front::wgsl::parse_str(super::super::java_stages::BLIT).unwrap(),
|
||||
"Java shader pack blits",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprites_take_their_blocks_ids() {
|
||||
let layers: BTreeMap<String, u32> = [
|
||||
("minecraft:block/oak_leaves", 0),
|
||||
("minecraft:block/water_still", 1),
|
||||
("minecraft:block/stone", 2),
|
||||
("minecraft:block/oak_log_top", 3),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(n, l)| (n.to_owned(), l))
|
||||
.collect();
|
||||
let ids: BTreeMap<String, i32> = [("oak_leaves", 10003), ("water", 8), ("oak_log", 7)]
|
||||
.into_iter()
|
||||
.map(|(n, i)| (n.to_owned(), i))
|
||||
.collect();
|
||||
assert_eq!(table(&layers, &ids), [2, 0, 10003, 8, -1, 7]);
|
||||
assert_eq!(atlas_columns(5), 3);
|
||||
assert_eq!(atlas_columns(0), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
|
||||
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
|
||||
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
|
||||
//! and materials, shadow map, noise texture and the previous stage's output,
|
||||
//! and writes its own target; the last target goes to the screen through
|
||||
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
|
||||
//! pack is selected.
|
||||
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
|
||||
use horizon_shaderpack::uniforms::SmoothFloat;
|
||||
|
||||
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameUniform {
|
||||
pub view: [[f32; 4]; 4],
|
||||
pub projection: [[f32; 4]; 4],
|
||||
pub inverse_view: [[f32; 4]; 4],
|
||||
pub inverse_projection: [[f32; 4]; 4],
|
||||
pub camera_position: [f32; 4],
|
||||
pub resolution: [f32; 4],
|
||||
pub time: [f32; 4],
|
||||
pub sun: [f32; 4],
|
||||
pub moon: [f32; 4],
|
||||
pub sky_color: [f32; 4],
|
||||
pub fog_color: [f32; 4],
|
||||
pub fog: [f32; 4],
|
||||
pub weather: [f32; 4],
|
||||
pub camera_state: [f32; 4],
|
||||
pub view_params: [f32; 4],
|
||||
pub dimension: [f32; 4],
|
||||
}
|
||||
|
||||
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct FrameExtUniform {
|
||||
pub previous_view: [[f32; 4]; 4],
|
||||
pub previous_projection: [[f32; 4]; 4],
|
||||
pub shadow_view: [[f32; 4]; 4],
|
||||
pub shadow_projection: [[f32; 4]; 4],
|
||||
pub shadow_inverse_view: [[f32; 4]; 4],
|
||||
pub shadow_inverse_projection: [[f32; 4]; 4],
|
||||
pub previous_camera: [f32; 4],
|
||||
pub shadow_params: [f32; 4],
|
||||
pub environment: [f32; 4],
|
||||
}
|
||||
|
||||
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
|
||||
/// Horizon packs get the same one unless `[textures] noise` replaces it.
|
||||
pub(super) fn noise() -> Rgba {
|
||||
const SIZE: u32 = 256;
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let pixels = (0..SIZE * SIZE * 4)
|
||||
.map(|i| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
|
||||
})
|
||||
.collect();
|
||||
Rgba {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
pixels,
|
||||
}
|
||||
}
|
||||
|
||||
/// `image` as a sampled RGBA8 texture.
|
||||
pub(super) fn rgba_view(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
image: &Rgba,
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.width,
|
||||
height: image.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: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&image.pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.width * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
scale: f32,
|
||||
target_format: wgpu::TextureFormat,
|
||||
uniform: wgpu::Buffer,
|
||||
uniform_ext: wgpu::Buffer,
|
||||
}
|
||||
/// A stage's output and the bindings that read the world into it.
|
||||
struct Target {
|
||||
size: [u32; 2],
|
||||
view: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct PackStages {
|
||||
pub id: String,
|
||||
pub needs_shadow: bool,
|
||||
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
|
||||
/// translucent geometry (`capture_opaque_depth`).
|
||||
pub needs_gbuffer: bool,
|
||||
pub needs_opaque_depth: bool,
|
||||
pub shadow_res: u32,
|
||||
pub shadow_dist: f32,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
shadow_sampler: wgpu::Sampler,
|
||||
noise_view: wgpu::TextureView,
|
||||
passes: Vec<Pass>,
|
||||
/// Built for this world target size and `world_post` generation.
|
||||
targets: Vec<Target>,
|
||||
/// `world_post`'s bindings of the last stage's output.
|
||||
output: Option<wgpu::BindGroup>,
|
||||
world_size: [u32; 2],
|
||||
post_generation: u64,
|
||||
started: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frames: u32,
|
||||
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
|
||||
wetness: SmoothFloat,
|
||||
}
|
||||
|
||||
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
|
||||
match name.map(str::to_lowercase).as_deref() {
|
||||
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
|
||||
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
impl PackStages {
|
||||
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
pack: &ShaderPack,
|
||||
) -> Result<Self, String> {
|
||||
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let sampler = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(ty),
|
||||
count: None,
|
||||
};
|
||||
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
texture(1, wgpu::TextureSampleType::Depth),
|
||||
texture(2, float),
|
||||
sampler(3, wgpu::SamplerBindingType::Filtering),
|
||||
sampler(4, wgpu::SamplerBindingType::NonFiltering),
|
||||
uniform_entry(5),
|
||||
uniform_entry(6),
|
||||
texture(7, float),
|
||||
texture(8, float),
|
||||
texture(9, wgpu::TextureSampleType::Depth),
|
||||
texture(10, wgpu::TextureSampleType::Depth),
|
||||
sampler(11, wgpu::SamplerBindingType::Comparison),
|
||||
texture(12, float),
|
||||
sampler(13, wgpu::SamplerBindingType::Filtering),
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let constants = pack.constants();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage_count = pack.stages.len();
|
||||
let passes = pack
|
||||
.stages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, stage)| {
|
||||
let is_last = idx + 1 == stage_count;
|
||||
let target_format = if is_last {
|
||||
format
|
||||
} else {
|
||||
stage_format(stage.format.as_deref(), format)
|
||||
};
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.file),
|
||||
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
|
||||
});
|
||||
// Only the overrides a stage declares may be passed to it.
|
||||
let constants: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(key, _)| declares_override(&stage.source, key))
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.file),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(VERTEX_ENTRY),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(&stage.entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: target_format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Pass {
|
||||
pipeline,
|
||||
scale: stage.scale,
|
||||
target_format,
|
||||
uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame"),
|
||||
size: std::mem::size_of::<FrameUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack frame ext"),
|
||||
size: std::mem::size_of::<FrameExtUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
let make_sampler = |filter, address, compare| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
compare,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let noise_view = match &pack.custom_noise {
|
||||
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
|
||||
None => rgba_view(device, queue, "Shader pack noise", &noise()),
|
||||
};
|
||||
let (shadow_res, shadow_dist) = pack.shadow_config();
|
||||
Ok(Self {
|
||||
id: pack.id.clone(),
|
||||
needs_shadow: pack.needs_shadow(),
|
||||
needs_gbuffer: pack.needs_gbuffer(),
|
||||
needs_opaque_depth: pack.needs_opaque_depth(),
|
||||
shadow_res,
|
||||
shadow_dist,
|
||||
layout,
|
||||
linear: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
nearest: make_sampler(
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
None,
|
||||
),
|
||||
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
|
||||
shadow_sampler: make_sampler(
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
Some(wgpu::CompareFunction::GreaterEqual),
|
||||
),
|
||||
noise_view,
|
||||
passes,
|
||||
targets: Vec::new(),
|
||||
output: None,
|
||||
world_size: [0, 0],
|
||||
post_generation: u64::MAX,
|
||||
started: std::time::Instant::now(),
|
||||
last: None,
|
||||
frames: 0,
|
||||
previous: None,
|
||||
wetness: SmoothFloat::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run every stage over the world's `color` and `depth` (`size`) and
|
||||
/// return `post`'s bindings of the last stage's output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
post: &super::world_post::Post,
|
||||
mut uniform: FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
) -> &wgpu::BindGroup {
|
||||
if self.world_size != size
|
||||
|| self.post_generation != post.generation()
|
||||
|| self.targets.len() != self.passes.len()
|
||||
{
|
||||
self.world_size = size;
|
||||
self.post_generation = post.generation();
|
||||
self.targets.clear();
|
||||
for pass in &self.passes {
|
||||
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
|
||||
let view = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
size: wgpu::Extent3d {
|
||||
width: target_size[0],
|
||||
height: target_size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: pass.target_format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let previous = self.targets.last().map_or(color, |t: &Target| &t.view);
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: pass.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: pass.uniform_ext.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(post.normal()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(post.material()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 12,
|
||||
resource: wgpu::BindingResource::TextureView(&self.noise_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 13,
|
||||
resource: wgpu::BindingResource::Sampler(&self.repeat),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets.push(Target {
|
||||
size: target_size,
|
||||
view,
|
||||
group,
|
||||
});
|
||||
}
|
||||
let last = &self.targets.last().expect("a pack has a stage").view;
|
||||
self.output = Some(post.bind(device, last));
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
|
||||
let cam = [
|
||||
uniform.camera_position[0],
|
||||
uniform.camera_position[1],
|
||||
uniform.camera_position[2],
|
||||
];
|
||||
let (prev_view, prev_proj, prev_cam) =
|
||||
self.previous
|
||||
.unwrap_or((uniform.view, uniform.projection, cam));
|
||||
self.previous = Some((uniform.view, uniform.projection, cam));
|
||||
let shadow_view = post.shadow_view;
|
||||
let shadow_proj = post.shadow_proj;
|
||||
let ext = FrameExtUniform {
|
||||
previous_view: prev_view,
|
||||
previous_projection: prev_proj,
|
||||
shadow_view: shadow_view.to_cols_array_2d(),
|
||||
shadow_projection: shadow_proj.to_cols_array_2d(),
|
||||
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
|
||||
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
|
||||
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
|
||||
shadow_params: [
|
||||
post.shadow_resolution as f32,
|
||||
post.shadow_distance,
|
||||
0.0,
|
||||
if post.shadow_active { 1.0 } else { 0.0 },
|
||||
],
|
||||
environment: [
|
||||
f32::from(eye_light[0]) * 16.0,
|
||||
f32::from(eye_light[1]) * 16.0,
|
||||
wet,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
uniform.time[0] = self.started.elapsed().as_secs_f32();
|
||||
uniform.time[1] = self.frames as f32;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
for (pass, target) in self.passes.iter().zip(&self.targets) {
|
||||
let [w, h] = target.size.map(|v| v as f32);
|
||||
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
|
||||
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target.view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack stage"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
render.set_pipeline(&pass.pipeline);
|
||||
render.set_bind_group(0, &target.group, &[]);
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
self.output.as_ref().expect("bound with the targets")
|
||||
}
|
||||
}
|
||||
|
||||
/// `override <key>` appears in the source (a declaration, not just a use).
|
||||
fn declares_override(source: &str, key: &str) -> bool {
|
||||
source.lines().any(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("override")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(key))
|
||||
.is_some_and(|rest| rest.trim_start().starts_with([':', '=']))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_frame_uniform_matches_the_prelude() {
|
||||
// 4 matrices and 12 vectors, as `struct Frame` in the prelude.
|
||||
assert_eq!(std::mem::size_of::<super::FrameUniform>(), 4 * 64 + 12 * 16);
|
||||
let prelude = crate::shader_pack::PRELUDE;
|
||||
let body = prelude.split("struct Frame {").nth(1).unwrap();
|
||||
let body = &body[..body.find('}').unwrap()];
|
||||
assert_eq!(body.matches("mat4x4<f32>").count(), 4);
|
||||
assert_eq!(body.matches("vec4<f32>").count(), 12);
|
||||
}
|
||||
#[test]
|
||||
fn only_declared_overrides_are_passed() {
|
||||
let source =
|
||||
"override saturation: f32 = 1.0;\noverride vignette = true;\nlet x = saturation_2;";
|
||||
assert!(super::declares_override(source, "saturation"));
|
||||
assert!(super::declares_override(source, "vignette"));
|
||||
assert!(!super::declares_override(source, "saturation_2"));
|
||||
assert!(!super::declares_override(source, "haze"));
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,25 @@
|
||||
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
|
||||
//! settings): the world renders into its own target at the scaled size, then
|
||||
//! one full-screen pass copies or anti-aliases it onto the screen, under the
|
||||
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
|
||||
//! - `opaque_depth`: the depth before translucent terrain (water, stained
|
||||
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
|
||||
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
|
||||
//! `scene_material`: surface normals and material properties
|
||||
//! reconstructed from depth and scene colour;
|
||||
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
|
||||
//! light-space `shadow_view` and `shadow_proj` matrices.
|
||||
//!
|
||||
//! With none of them on, the world renders straight to the screen as before.
|
||||
|
||||
use super::pack_stages::FrameUniform;
|
||||
use glam::{Mat4, Vec3};
|
||||
|
||||
const GBUFFER_WGSL: &str = r#"
|
||||
struct GbufferParams {
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
|
||||
env: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var world_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(3) var nearest_sampler: sampler;
|
||||
@group(0) @binding(4) var<uniform> params: GbufferParams;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: VsOut;
|
||||
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
struct GbufferOut {
|
||||
@location(0) normal: vec4<f32>,
|
||||
@location(1) material: vec4<f32>,
|
||||
}
|
||||
|
||||
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
|
||||
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
|
||||
let view = v.xyz / max(v.w, 1e-6);
|
||||
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_gbuffer(in: VsOut) -> GbufferOut {
|
||||
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
|
||||
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
|
||||
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
|
||||
var out: GbufferOut;
|
||||
if d_all <= 0.0 && d_opq <= 0.0 {
|
||||
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
|
||||
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
let d = select(d_all, d_opq, d_opq > 0.0);
|
||||
let step_uv = params.resolution.zw;
|
||||
let p0 = world_pos(in.uv, d);
|
||||
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
|
||||
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
|
||||
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
|
||||
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
|
||||
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
|
||||
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
|
||||
let raw_n = cross(dy, dx);
|
||||
let n_len = length(raw_n);
|
||||
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
|
||||
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
|
||||
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
|
||||
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
|
||||
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
|
||||
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
|
||||
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
|
||||
return out;
|
||||
}
|
||||
"#;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct GbufferUniform {
|
||||
inverse_view: [[f32; 4]; 4],
|
||||
inverse_projection: [[f32; 4]; 4],
|
||||
resolution: [f32; 4],
|
||||
env: [f32; 4],
|
||||
}
|
||||
|
||||
/// What the frame's shader pack reads beyond the world's colour and depth:
|
||||
/// `Post` keeps these targets only while one does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Extras {
|
||||
pub opaque_depth: bool,
|
||||
pub gbuffer: bool,
|
||||
}
|
||||
//! Resolution scale and FXAA (Horizon settings): the world renders into its
|
||||
//! own target at the scaled size, then one full-screen pass copies or
|
||||
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
|
||||
//! the world renders straight to the screen.
|
||||
|
||||
struct WorldTarget {
|
||||
size: [u32; 2],
|
||||
extras: Extras,
|
||||
color: wgpu::TextureView,
|
||||
depth_tex: wgpu::Texture,
|
||||
depth: wgpu::TextureView,
|
||||
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
|
||||
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
gbuffer: Option<Gbuffer>,
|
||||
group: wgpu::BindGroup,
|
||||
opaque_captured: bool,
|
||||
}
|
||||
|
||||
struct Gbuffer {
|
||||
normal: wgpu::TextureView,
|
||||
material: wgpu::TextureView,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct ShadowTarget {
|
||||
resolution: u32,
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
gbuffer_layout: wgpu::BindGroupLayout,
|
||||
gbuffer_pipeline: wgpu::RenderPipeline,
|
||||
gbuffer_uniform: wgpu::Buffer,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
pub antialias: bool,
|
||||
target: Option<WorldTarget>,
|
||||
shadow: Option<ShadowTarget>,
|
||||
/// Bumped whenever a target is made: bindings of the old ones are stale.
|
||||
generation: u64,
|
||||
fallback_shadow: wgpu::TextureView,
|
||||
fallback_color: wgpu::TextureView,
|
||||
pub shadow_view: Mat4,
|
||||
pub shadow_proj: Mat4,
|
||||
pub shadow_distance: f32,
|
||||
pub shadow_resolution: u32,
|
||||
pub shadow_active: bool,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -225,192 +87,31 @@ impl Post {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
|
||||
});
|
||||
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
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: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
bind_group_layouts: &[Some(&gbuffer_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: Some(&gbuffer_pipe_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("vs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &gbuffer_shader,
|
||||
entry_point: Some("fs_gbuffer"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon gbuffer uniform"),
|
||||
size: std::mem::size_of::<GbufferUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let fallback_shadow = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback shadow"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
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: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let fallback_color = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon fallback gbuffer"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
Self {
|
||||
copy: pipeline("fs_copy"),
|
||||
fxaa: pipeline("fs_fxaa"),
|
||||
layout,
|
||||
sampler,
|
||||
nearest,
|
||||
gbuffer_layout,
|
||||
gbuffer_pipeline,
|
||||
gbuffer_uniform,
|
||||
format,
|
||||
scale: 1.0,
|
||||
antialias: false,
|
||||
target: None,
|
||||
shadow: None,
|
||||
generation: 0,
|
||||
fallback_shadow,
|
||||
fallback_color,
|
||||
shadow_view: Mat4::IDENTITY,
|
||||
shadow_proj: Mat4::IDENTITY,
|
||||
shadow_distance: 128.0,
|
||||
shadow_resolution: 2048,
|
||||
shadow_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen. `pack`: a shader pack runs this frame,
|
||||
/// reading `extras`.
|
||||
/// it straight to the screen.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
pack: bool,
|
||||
extras: Extras,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias && !pack {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
self.target = None;
|
||||
self.shadow_active = false;
|
||||
return None;
|
||||
}
|
||||
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.extras != extras)
|
||||
{
|
||||
self.generation += 1;
|
||||
if self.target.as_ref().is_none_or(|t| t.size != size) {
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
@@ -434,273 +135,26 @@ impl Post {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let depth_tex = make_tex(
|
||||
// TEXTURE_BINDING: occlusion culling reads it.
|
||||
let depth = make_tex(
|
||||
"Scaled world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = depth_tex.create_view(&Default::default());
|
||||
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
|
||||
let tex = make_tex(
|
||||
"Opaque world depth",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
(tex, view)
|
||||
});
|
||||
let gbuffer = opaque_depth
|
||||
.as_ref()
|
||||
.filter(|_| extras.gbuffer)
|
||||
.map(|(_, opaque)| {
|
||||
let color_target = |label| {
|
||||
make_tex(
|
||||
label,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
layout: &self.gbuffer_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&color),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(opaque),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.gbuffer_uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
Gbuffer {
|
||||
normal: color_target("World G-buffer normal"),
|
||||
material: color_target("World G-buffer material"),
|
||||
group,
|
||||
}
|
||||
});
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
self.target = Some(WorldTarget {
|
||||
size,
|
||||
extras,
|
||||
color,
|
||||
depth_tex,
|
||||
depth,
|
||||
opaque_depth,
|
||||
gbuffer,
|
||||
group,
|
||||
opaque_captured: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = &mut self.target {
|
||||
t.opaque_captured = false;
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// Prepare the directional sun/moon shadow map target and compute its
|
||||
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
|
||||
pub fn prepare_shadow(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
resolution: u32,
|
||||
distance: f32,
|
||||
sun_dir: Vec3,
|
||||
moon_dir: Vec3,
|
||||
) -> &wgpu::TextureView {
|
||||
let resolution = resolution.clamp(256, 4096);
|
||||
let distance = distance.clamp(16.0, 512.0);
|
||||
if self
|
||||
.shadow
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.resolution != resolution)
|
||||
{
|
||||
let depth = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon shadow map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: resolution,
|
||||
height: resolution,
|
||||
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: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
self.shadow = Some(ShadowTarget { resolution, depth });
|
||||
self.generation += 1;
|
||||
}
|
||||
let light_dir = if sun_dir.y >= -0.05 {
|
||||
sun_dir.normalize_or_zero()
|
||||
} else {
|
||||
moon_dir.normalize_or_zero()
|
||||
};
|
||||
let dir = if light_dir.length_squared() > 1e-4 {
|
||||
light_dir
|
||||
} else {
|
||||
Vec3::Y
|
||||
};
|
||||
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
|
||||
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
|
||||
// Reversed-depth orthographic projection for the world's GreaterEqual
|
||||
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
|
||||
// projection for shader sampling.
|
||||
self.shadow_proj = Mat4::orthographic_rh(
|
||||
-distance,
|
||||
distance,
|
||||
-distance,
|
||||
distance,
|
||||
distance * 2.5,
|
||||
0.05,
|
||||
);
|
||||
self.shadow_distance = distance;
|
||||
self.shadow_resolution = resolution;
|
||||
self.shadow_active = true;
|
||||
&self.shadow.as_ref().expect("created above").depth
|
||||
}
|
||||
|
||||
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
|
||||
/// right before translucent water and weather draw.
|
||||
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &mut self.target else { return };
|
||||
let Some((opaque, _)) = &t.opaque_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.depth_tex.as_image_copy(),
|
||||
opaque.as_image_copy(),
|
||||
t.depth_tex.size(),
|
||||
);
|
||||
t.opaque_captured = true;
|
||||
}
|
||||
|
||||
/// Reconstruct the G-buffer `normal` and `material` textures after the
|
||||
/// world pass finishes (with `Extras::gbuffer`).
|
||||
pub fn build_gbuffer(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &FrameUniform,
|
||||
eye_light: [u8; 2],
|
||||
wetness: f32,
|
||||
) {
|
||||
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
|
||||
self.capture_opaque_depth(encoder);
|
||||
}
|
||||
let Some(t) = &self.target else { return };
|
||||
let Some(gbuffer) = &t.gbuffer else { return };
|
||||
let [w, h] = t.size.map(|v| v.max(1) as f32);
|
||||
let uniform = GbufferUniform {
|
||||
inverse_view: frame.inverse_view,
|
||||
inverse_projection: frame.inverse_projection,
|
||||
resolution: [w, h, 1.0 / w, 1.0 / h],
|
||||
env: [
|
||||
f32::from(eye_light[0]) / 15.0,
|
||||
f32::from(eye_light[1]) / 15.0,
|
||||
wetness,
|
||||
frame.sun[1],
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.normal,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: &gbuffer.material,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}),
|
||||
];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon gbuffer"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.gbuffer_pipeline);
|
||||
pass.set_bind_group(0, &gbuffer.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world target's size, once `prepare` made one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
|
||||
/// bindings made from the views below must be made again.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The depth before translucent geometry (`capture_opaque_depth`), or
|
||||
/// the world's depth when nothing reads it.
|
||||
pub fn opaque_depth(&self) -> &wgpu::TextureView {
|
||||
match &self.target {
|
||||
Some(WorldTarget {
|
||||
opaque_depth: Some((_, view)),
|
||||
..
|
||||
}) => view,
|
||||
Some(t) => &t.depth,
|
||||
None => &self.fallback_shadow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructed world-space normals (`scene_normal`).
|
||||
pub fn normal(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
|
||||
}
|
||||
|
||||
/// Reconstructed material and light (`scene_material`).
|
||||
pub fn material(&self) -> &wgpu::TextureView {
|
||||
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
|
||||
}
|
||||
|
||||
fn gbuffer(&self) -> Option<&Gbuffer> {
|
||||
self.target.as_ref()?.gbuffer.as_ref()
|
||||
}
|
||||
|
||||
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
|
||||
pub fn shadow_depth(&self) -> &wgpu::TextureView {
|
||||
self.shadow
|
||||
.as_ref()
|
||||
.map_or(&self.fallback_shadow, |s| &s.depth)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -252,9 +252,6 @@ pub struct Graphics {
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
pub battery: bool,
|
||||
/// The Horizon shader pack in `shaderpacks/` (its folder name); empty:
|
||||
/// none, the vanilla picture (docs/shader-packs.md).
|
||||
pub shader_pack: String,
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
}
|
||||
@@ -280,7 +277,6 @@ impl Default for Graphics {
|
||||
resolution_scale: 100,
|
||||
fxaa: false,
|
||||
battery: false,
|
||||
shader_pack: String::new(),
|
||||
api: GraphicsApi::Auto,
|
||||
}
|
||||
}
|
||||
@@ -332,7 +328,6 @@ impl Graphics {
|
||||
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shaderPack" => g.shader_pack = value.to_string(),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"overlay" => {
|
||||
g.overlay = match value {
|
||||
@@ -348,7 +343,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -375,7 +370,6 @@ impl Graphics {
|
||||
self.resolution_scale,
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.battery),
|
||||
self.shader_pack,
|
||||
self.api.name(),
|
||||
)
|
||||
}
|
||||
@@ -469,13 +463,17 @@ mod tests {
|
||||
resolution_scale: 75,
|
||||
fxaa: true,
|
||||
battery: true,
|
||||
shader_pack: "horizon-sample".into(),
|
||||
api: GraphicsApi::Dx12,
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// The old menus' Simple / Advanced choice is ignored.
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
assert_eq!(
|
||||
Graphics::parse("shaderPack:Pack.zip\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
|
||||
@@ -39,7 +39,6 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
BatteryMode,
|
||||
ShaderPack,
|
||||
GraphicsApi,
|
||||
}
|
||||
|
||||
@@ -112,18 +111,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Waits for the display's refresh: no tearing, a little latency.",
|
||||
);
|
||||
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
|
||||
let shader_pack = h(
|
||||
H::ShaderPack,
|
||||
High,
|
||||
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
|
||||
);
|
||||
let preset = h(
|
||||
H::Preset,
|
||||
None,
|
||||
"Sets the options below together. Changing one makes it Custom.",
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -251,7 +245,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lowers the frame rate when minimised, or idle too.",
|
||||
),
|
||||
shader_pack,
|
||||
Row::Header("Preferences"),
|
||||
v(Opt::ViewBobbing, None, "Sways the view while walking."),
|
||||
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
|
||||
@@ -381,10 +374,6 @@ impl H {
|
||||
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::ShaderPack => match g.shader_pack.as_str() {
|
||||
"" => labelled("Shader Pack", "Off"),
|
||||
id => labelled("Shader Pack", id),
|
||||
},
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
@@ -527,7 +516,6 @@ impl Item {
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::BatteryMode
|
||||
| H::ShaderPack
|
||||
| H::GraphicsApi
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
@@ -557,8 +545,6 @@ pub enum Action {
|
||||
Changed,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
/// The Shader Pack row: the Shader Packs page (where packs are picked).
|
||||
OpenShaderPacks,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -784,9 +770,6 @@ impl GraphicsUi {
|
||||
}
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
Target::Item(Item::Horizon(H::ShaderPack)) => {
|
||||
vec![Action::ButtonClick, Action::OpenShaderPacks]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -994,44 +977,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_pack_row_is_off_by_default_and_outside_presets() {
|
||||
let mut s = Settings::default();
|
||||
let row = Item::Horizon(H::ShaderPack);
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: Off");
|
||||
assert!(
|
||||
rows(Level::Advanced)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == row))
|
||||
);
|
||||
s.horizon.shader_pack = "horizon-sample".into();
|
||||
assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample");
|
||||
// The row opens the Shader Packs page, where packs are picked.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let find = |ui: &mut GraphicsUi| {
|
||||
let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0);
|
||||
widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == row))
|
||||
.unwrap()
|
||||
};
|
||||
ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0;
|
||||
let w = find(&mut ui);
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(w.x + 1.0, w.y + 1.0),
|
||||
(854.0, 480.0),
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
[Action::ButtonClick, Action::OpenShaderPacks]
|
||||
);
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_set_vanilla_and_horizon_options() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
//! Java (OptiFine/Iris) shader packs in `shaderpacks/` (docs/shader-packs.md):
|
||||
//! a folder holding `shaders/`, or a `.zip`. `horizon-shaderpack` translates
|
||||
//! their full-screen programs (`begin`, `prepare`, `deferred`, `composite`,
|
||||
//! `final`) to WGSL, and `gfx/java_stages.rs` runs them over Horizon's world
|
||||
//! as Iris runs them over its own. Of the geometry programs, the terrain's
|
||||
//! (`gbuffers_terrain`, `gbuffers_water` and their fallbacks) draw Horizon's
|
||||
//! terrain (`gfx/java_terrain.rs`); the rest of the world is Horizon's.
|
||||
use horizon_shaderpack::{
|
||||
JavaPack,
|
||||
pipeline::{Directives, GeometryProgram, Pass, Program, TargetSettings},
|
||||
preprocess::Environment,
|
||||
properties::{Blend, SkyDirectives},
|
||||
uniforms::CustomUniforms,
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// The passes of one program folder (`world0`, or `""` for the pack's root).
|
||||
pub struct JavaFolder {
|
||||
pub folder: String,
|
||||
pub targets: Vec<TargetSettings>,
|
||||
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
|
||||
pub passes: Vec<Program>,
|
||||
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
|
||||
/// world's colour and depth enter `world_target` and `depthtex*` after them.
|
||||
pub world_at: usize,
|
||||
/// The colour target the world goes into, where the pack's terrain
|
||||
/// program draws (MakeUp: colortex1).
|
||||
pub world_target: u32,
|
||||
/// The targets whose alternate side the world is written to.
|
||||
pub flipped_after_prepare: BTreeSet<u32>,
|
||||
pub final_pass: Option<Program>,
|
||||
/// The targets whose alternate side holds the frame's last write.
|
||||
pub flipped_at_end: BTreeSet<u32>,
|
||||
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
|
||||
/// `drynessHalflife`.
|
||||
pub directives: Directives,
|
||||
/// The programs Horizon's solid and translucent terrain are drawn with,
|
||||
/// each translated for instanced and flat draws (`variants[0]`).
|
||||
pub terrain: Option<GeometryProgram>,
|
||||
pub water: Option<GeometryProgram>,
|
||||
/// How many of `passes` run before translucent terrain (`begin*`,
|
||||
/// `prepare*`, `deferred*`).
|
||||
pub translucent_at: usize,
|
||||
pub flipped_after_translucent: BTreeSet<u32>,
|
||||
/// Each draw buffer's blending in `terrain` and `water`.
|
||||
pub terrain_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// The shadow map's programs over Horizon's solid and translucent
|
||||
/// terrain (instanced draws only), drawn after `begin*`, into
|
||||
/// `shadowcolor` targets set by `shadow_targets`.
|
||||
pub shadow: Option<GeometryProgram>,
|
||||
pub shadow_water: Option<GeometryProgram>,
|
||||
pub shadow_targets: Vec<TargetSettings>,
|
||||
pub shadow_blends: Vec<Option<wgpu::BlendState>>,
|
||||
pub shadow_water_blends: Vec<Option<wgpu::BlendState>>,
|
||||
/// How many of `passes` run before the shadow map (`begin*`).
|
||||
pub shadow_at: usize,
|
||||
}
|
||||
|
||||
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
|
||||
pub const NOISE_KEY: &str = "noise";
|
||||
|
||||
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
|
||||
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
|
||||
/// `final` reads the `composite` ones.
|
||||
pub fn texture_key(program: &str, sampler: &str) -> String {
|
||||
let name = program.rsplit('/').next().unwrap_or(program);
|
||||
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
|
||||
"final" => "composite",
|
||||
stage => stage,
|
||||
};
|
||||
format!("{stage}.{sampler}")
|
||||
}
|
||||
|
||||
/// A loaded Java pack.
|
||||
pub struct JavaPasses {
|
||||
pub folders: Vec<JavaFolder>,
|
||||
/// `uniform.*` / `variable.*` from `shaders.properties`.
|
||||
pub uniforms: CustomUniforms,
|
||||
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
|
||||
pub sky: SkyDirectives,
|
||||
/// `block.properties`: block name → `mc_Entity` id.
|
||||
pub block_ids: BTreeMap<String, i32>,
|
||||
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
|
||||
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
|
||||
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
|
||||
}
|
||||
|
||||
impl JavaPasses {
|
||||
/// The folder Iris uses in `dimension` (0 overworld, -1 the Nether, 1 the
|
||||
/// End): `world<N>` when the pack has one, else `world0`, else the root.
|
||||
pub fn folder_for(&self, dimension: i8) -> Option<usize> {
|
||||
let find = |name: &str| self.folders.iter().position(|f| f.folder == name);
|
||||
find(&format!("world{dimension}"))
|
||||
.or_else(|| find("world0"))
|
||||
.or_else(|| find(""))
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder holding `shaders/`, or a zip.
|
||||
pub fn is_java_pack(path: &Path) -> bool {
|
||||
path.join("shaders").is_dir()
|
||||
|| path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
||||
}
|
||||
|
||||
/// The `MC_GL_VENDOR_*` name of a wgpu adapter (`AdapterInfo::vendor`, a
|
||||
/// PCI id; Apple's GPUs report 0).
|
||||
pub fn vendor_name(vendor: u32, adapter: &str) -> &'static str {
|
||||
match vendor {
|
||||
0x10de => "NVIDIA Corporation",
|
||||
0x1002 | 0x1022 => "AMD",
|
||||
0x8086 => "Intel",
|
||||
_ if adapter.starts_with("Apple") => "Apple",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the pack at `path` and translate its full-screen passes with the
|
||||
/// player's `values`; `vendor` and `renderer` name the GPU to the pack.
|
||||
pub fn load(
|
||||
path: &Path,
|
||||
values: &BTreeMap<String, String>,
|
||||
vendor: &str,
|
||||
renderer: &str,
|
||||
) -> Result<JavaPasses, String> {
|
||||
let pack = JavaPack::open(path)?;
|
||||
let values = pack.values(values);
|
||||
let env = Environment::iris(vendor, renderer, 4);
|
||||
let properties = pack.properties(&values, &env)?;
|
||||
let mut names: Vec<&str> = ["world0", "world-1", "world1"]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
pack.programs
|
||||
.iter()
|
||||
.any(|p| p.starts_with(&format!("{d}/")))
|
||||
})
|
||||
.collect();
|
||||
if pack.programs.iter().any(|p| !p.contains('/')) {
|
||||
names.push("");
|
||||
}
|
||||
let mut folders = Vec::new();
|
||||
for name in names {
|
||||
let plan = pack.passes(name, &values, &env, &properties)?;
|
||||
let compile = |pass| {
|
||||
let program = pack.compile(pass, &plan.targets, &values, &env, &properties)?;
|
||||
if let Some((image, _)) = program.fragment.interface.images.first() {
|
||||
return Err(format!(
|
||||
"{}: images ({image}) aren't supported yet",
|
||||
program.pass.program
|
||||
));
|
||||
}
|
||||
Ok::<_, String>(program)
|
||||
};
|
||||
let early = plan.begin.iter().chain(&plan.prepare);
|
||||
let passes = early
|
||||
.clone()
|
||||
.chain(&plan.deferred)
|
||||
.chain(&plan.composite)
|
||||
.map(compile)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let final_pass = plan.final_pass.as_ref().map(compile).transpose()?;
|
||||
if passes.is_empty() && final_pass.is_none() {
|
||||
continue;
|
||||
}
|
||||
// A geometry program that doesn't translate leaves that terrain to
|
||||
// Horizon, with a warning.
|
||||
let geometry_with = |pass: &Option<horizon_shaderpack::pipeline::Pass>,
|
||||
alpha_test,
|
||||
flat: &[bool]| {
|
||||
let pass = pass.as_ref()?;
|
||||
let glues: Vec<_> = flat
|
||||
.iter()
|
||||
.map(|&flat| crate::gfx::java_terrain::glue(flat, alpha_test))
|
||||
.collect();
|
||||
match pack.compile_geometry(pass, &plan.targets, &values, &env, &properties, &glues) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: shader pack {}: {e}", pass.program);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let geometry = |pass: &Option<horizon_shaderpack::pipeline::Pass>, alpha_test| {
|
||||
geometry_with(pass, alpha_test, &[false, true])
|
||||
};
|
||||
// Iris's alpha test of cutout terrain (`AlphaTests.ONE_TENTH_ALPHA`).
|
||||
let terrain = geometry(&plan.terrain, Some(0.1));
|
||||
let water = terrain.as_ref().and_then(|_| geometry(&plan.water, None));
|
||||
// The shadow map draws only with the pack's terrain, and only from
|
||||
// instanced draws (the flat draws hold what the camera sees).
|
||||
let shadow = terrain
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow, Some(0.1), &[false]));
|
||||
let shadow_water = shadow
|
||||
.as_ref()
|
||||
.and_then(|_| geometry_with(&plan.shadow_water, None, &[false]));
|
||||
let translucent_at = early.clone().count() + plan.deferred.len();
|
||||
let blends = |g: &Option<GeometryProgram>, default| {
|
||||
g.as_ref().map_or_else(Vec::new, |g| {
|
||||
pass_blends(&g.program.pass, &g.program.draw_buffers, default)
|
||||
})
|
||||
};
|
||||
let terrain_blends = blends(&terrain, None);
|
||||
// Iris's shadow programs don't blend (`BlendModeOverride.OFF`).
|
||||
let shadow_blends = blends(&shadow, None);
|
||||
let shadow_water_blends = blends(&shadow_water, None);
|
||||
// Vanilla's translucent blending (`BlendFunction.TRANSLUCENT`).
|
||||
let water_blends = blends(
|
||||
&water,
|
||||
Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
);
|
||||
folders.push(JavaFolder {
|
||||
folder: name.to_owned(),
|
||||
targets: plan.targets,
|
||||
world_at: early.count(),
|
||||
world_target: plan.world_target,
|
||||
passes,
|
||||
flipped_after_prepare: plan.flipped_after_prepare,
|
||||
final_pass,
|
||||
flipped_at_end: plan.flipped_at_end,
|
||||
directives: plan.directives,
|
||||
terrain,
|
||||
water,
|
||||
translucent_at,
|
||||
flipped_after_translucent: plan.flipped_after_translucent,
|
||||
terrain_blends,
|
||||
water_blends,
|
||||
shadow,
|
||||
shadow_water,
|
||||
shadow_targets: plan.shadow_targets,
|
||||
shadow_blends,
|
||||
shadow_water_blends,
|
||||
shadow_at: plan.begin.len(),
|
||||
});
|
||||
}
|
||||
if folders.is_empty() {
|
||||
return Err("no deferred, composite or final programs".into());
|
||||
}
|
||||
let mut custom_textures = BTreeMap::new();
|
||||
for tex in properties.custom_textures() {
|
||||
let key = match tex.stage {
|
||||
Some(stage) => format!("{stage}.{}", tex.sampler),
|
||||
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
// Only PNGs for now: resource locations and raw `.dat` textures are
|
||||
// skipped and the sampler reads what it would without them.
|
||||
let image = pack
|
||||
.files
|
||||
.read_bytes(&tex.path)
|
||||
.ok_or_else(|| "not in the pack".to_owned())
|
||||
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
|
||||
match image {
|
||||
Ok(image) => {
|
||||
custom_textures.insert(key, image);
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
|
||||
}
|
||||
}
|
||||
let uniforms = properties.custom_uniforms();
|
||||
for problem in &uniforms.problems {
|
||||
log::warn!("Horizon: shader pack custom uniform {problem}");
|
||||
}
|
||||
let block_ids = pack.block_ids(&values, &env).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {e}");
|
||||
BTreeMap::new()
|
||||
});
|
||||
Ok(JavaPasses {
|
||||
folders,
|
||||
uniforms,
|
||||
sky: properties.sky(),
|
||||
custom_textures,
|
||||
block_ids,
|
||||
})
|
||||
}
|
||||
|
||||
/// Each draw buffer's blending, as Iris's `BlendModeOverride`:
|
||||
/// `blend.<program>.<buffer>`, else `blend.<program>`, else `default`
|
||||
/// (none for full-screen passes, vanilla's for the geometry programs).
|
||||
pub(crate) fn pass_blends(
|
||||
pass: &Pass,
|
||||
draw_buffers: &[u32],
|
||||
default: Option<wgpu::BlendState>,
|
||||
) -> Vec<Option<wgpu::BlendState>> {
|
||||
let all = pass.blend.as_ref().map_or(default, blend_state);
|
||||
draw_buffers
|
||||
.iter()
|
||||
.map(|i| {
|
||||
pass.buffer_blends
|
||||
.iter()
|
||||
.find(|(b, _)| b == i)
|
||||
.map_or(all, |(_, b)| blend_state(b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A `blend.*` value as wgpu's blending (`off`: none). The factors are the
|
||||
/// GL ones `ShaderProperties` accepts.
|
||||
pub(crate) fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
|
||||
let Blend::Factors(factors) = blend else {
|
||||
return None;
|
||||
};
|
||||
let factor = |name: &str| {
|
||||
use wgpu::BlendFactor as F;
|
||||
match name {
|
||||
"ZERO" => F::Zero,
|
||||
"SRC_COLOR" => F::Src,
|
||||
"ONE_MINUS_SRC_COLOR" => F::OneMinusSrc,
|
||||
"DST_COLOR" => F::Dst,
|
||||
"ONE_MINUS_DST_COLOR" => F::OneMinusDst,
|
||||
"SRC_ALPHA" => F::SrcAlpha,
|
||||
"ONE_MINUS_SRC_ALPHA" => F::OneMinusSrcAlpha,
|
||||
"DST_ALPHA" => F::DstAlpha,
|
||||
"ONE_MINUS_DST_ALPHA" => F::OneMinusDstAlpha,
|
||||
"SRC_ALPHA_SATURATE" => F::SrcAlphaSaturated,
|
||||
_ => F::One,
|
||||
}
|
||||
};
|
||||
let component = |src: &str, dst: &str| wgpu::BlendComponent {
|
||||
src_factor: factor(src),
|
||||
dst_factor: factor(dst),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
Some(wgpu::BlendState {
|
||||
color: component(&factors[0], &factors[1]),
|
||||
alpha: component(&factors[2], &factors[3]),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_textures_belong_to_iris_s_stages() {
|
||||
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
|
||||
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
|
||||
assert_eq!(
|
||||
texture_key("world-1/deferred10", "noisetex"),
|
||||
"deferred.noisetex"
|
||||
);
|
||||
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_full_screen_passes_of_a_java_pack() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-shaderpack/tests/fixtures/passes");
|
||||
assert!(is_java_pack(&dir));
|
||||
let pack = load(&dir, &BTreeMap::new(), "Apple", "Apple M4 Pro").unwrap();
|
||||
let [folder] = &pack.folders[..] else {
|
||||
panic!("one folder, the root");
|
||||
};
|
||||
assert_eq!(folder.folder, "");
|
||||
assert_eq!(
|
||||
folder
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.pass.program.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["deferred", "composite", "composite1"]
|
||||
);
|
||||
assert_eq!(folder.world_at, 0);
|
||||
assert!(folder.final_pass.is_some());
|
||||
// The overworld, the Nether and the End all use the root.
|
||||
assert_eq!([-1, 0, 1].map(|d| pack.folder_for(d)), [Some(0); 3]);
|
||||
assert_eq!(
|
||||
vendor_name(0x10de, "NVIDIA GeForce RTX 4080"),
|
||||
"NVIDIA Corporation"
|
||||
);
|
||||
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
|
||||
}
|
||||
|
||||
/// wgpu is given the WGSL text: it must parse again.
|
||||
fn reparse_error(pack: &JavaPasses) -> Option<String> {
|
||||
let passes = pack
|
||||
.folders
|
||||
.iter()
|
||||
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
|
||||
let geometry = pack.folders.iter().flat_map(|f| {
|
||||
f.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
});
|
||||
passes
|
||||
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
|
||||
.chain(geometry.flat_map(|g| {
|
||||
let p = &g.program;
|
||||
[(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")]
|
||||
.into_iter()
|
||||
.chain(g.variants.iter().map(move |v| (p, v, "vsh (flat)")))
|
||||
}))
|
||||
.find_map(|(p, t, ext)| {
|
||||
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
|
||||
Some(format!(
|
||||
"{}.{ext}: {}",
|
||||
p.pass.program,
|
||||
e.emit_to_string(&t.wgsl)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
|
||||
/// game loads them, with their default options: the compatibility table
|
||||
/// of docs/shader-packs.md. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_load() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| is_java_pack(p))
|
||||
.collect();
|
||||
entries.sort();
|
||||
let mut failures = Vec::new();
|
||||
for path in entries {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
|
||||
Ok(pack) if let Some(e) = reparse_error(&pack) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
Ok(pack) => {
|
||||
let folders: Vec<String> = pack
|
||||
.folders
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let n = f.passes.len() + usize::from(f.final_pass.is_some());
|
||||
let geometry: Vec<&str> = f
|
||||
.terrain
|
||||
.iter()
|
||||
.chain(&f.water)
|
||||
.chain(&f.shadow)
|
||||
.chain(&f.shadow_water)
|
||||
.map(|g| g.program.pass.program.as_str())
|
||||
.collect();
|
||||
format!("{} {n} passes, terrain {geometry:?}", f.folder)
|
||||
})
|
||||
.collect();
|
||||
println!("{name}: loads ({})", folders.join(", "));
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{name}: {e}");
|
||||
failures.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,6 @@ pub mod item_data;
|
||||
pub mod item_render;
|
||||
pub mod item_tooltip;
|
||||
pub mod items;
|
||||
pub mod java_pack;
|
||||
pub mod java_text;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
@@ -159,7 +158,6 @@ pub mod screen_layout;
|
||||
pub mod sculk;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod settings;
|
||||
pub mod shader_pack;
|
||||
pub mod shadows;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
|
||||
@@ -10,7 +10,6 @@ use crate::{
|
||||
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
shader_pack::PackConfig,
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -863,10 +862,6 @@ pub enum Page {
|
||||
Font,
|
||||
Packs,
|
||||
Mods,
|
||||
/// The shader packs in `shaderpacks/`: pick one, open the folder.
|
||||
ShaderPacks,
|
||||
/// Interactive options and profiles for the active shader pack.
|
||||
ShaderPackOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -898,17 +893,9 @@ enum Act {
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
OpenModFolder,
|
||||
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
|
||||
ToggleShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// Credits & Attribution's Credits button (WinScreen without the poem).
|
||||
Credits,
|
||||
CopyAgentDetails,
|
||||
ShaderPackProfile,
|
||||
ShaderPackSubscreen(String),
|
||||
ShaderPackBackScreen,
|
||||
ShaderPackOption(usize),
|
||||
ResetShaderPackOptions,
|
||||
}
|
||||
enum WKind {
|
||||
Button {
|
||||
@@ -921,11 +908,6 @@ enum WKind {
|
||||
value: f32,
|
||||
opt: Opt,
|
||||
},
|
||||
ShaderOptionSlider {
|
||||
label: Text,
|
||||
value: f32,
|
||||
index: usize,
|
||||
},
|
||||
Text {
|
||||
text: Text,
|
||||
color: u32,
|
||||
@@ -961,15 +943,10 @@ pub enum Event {
|
||||
Packs(Vec<String>),
|
||||
OpenPackFolder,
|
||||
OpenModFolder,
|
||||
/// Create `shaderpacks/` if needed and open it.
|
||||
OpenShaderPackFolder,
|
||||
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
|
||||
Credits,
|
||||
/// Copy how an agent connects (the AI Agent page).
|
||||
CopyAgentDetails,
|
||||
/// The shader pack settings page closed with changes: they are saved,
|
||||
/// the pack is built again with them.
|
||||
ReloadShaderPack,
|
||||
}
|
||||
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
|
||||
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
|
||||
@@ -1021,19 +998,8 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
/// `shaderpacks/` and its packs, read while the Shader Packs page shows.
|
||||
pub shader_packs: crate::shader_pack::Folder,
|
||||
/// The pack that failed to load last and why (set by the game).
|
||||
pub shader_pack_error: Option<(String, String)>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
/// Loaded interactive configuration for the currently selected shader pack.
|
||||
pub shader_pack_config: Option<PackConfig>,
|
||||
/// The shader pack option slider being dragged.
|
||||
pub(crate) dragging_shader_option: Option<usize>,
|
||||
/// `shader_pack_config` changed since the settings page opened: leaving
|
||||
/// it saves the values and reloads the pack.
|
||||
shader_pack_edited: bool,
|
||||
}
|
||||
impl Default for OptionsUi {
|
||||
fn default() -> Self {
|
||||
@@ -1062,12 +1028,7 @@ impl Default for OptionsUi {
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
shader_packs: Default::default(),
|
||||
shader_pack_error: None,
|
||||
agent_status: Vec::new(),
|
||||
shader_pack_config: None,
|
||||
dragging_shader_option: None,
|
||||
shader_pack_edited: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1114,14 +1075,11 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => {
|
||||
"Horizon's own settings: graphics, shader packs, controllers, extras and mods."
|
||||
}
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::ShaderPacks => "Packs that redraw the world. Off is the vanilla look.",
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
@@ -1196,105 +1154,6 @@ impl OptionsUi {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
/// Pick shader pack `i` of the folder, or turn it off when it is the
|
||||
/// one running.
|
||||
pub(crate) fn toggle_shader_pack(&mut self, s: &mut Settings, i: usize) {
|
||||
let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else {
|
||||
return;
|
||||
};
|
||||
let g = &mut s.horizon;
|
||||
g.shader_pack = if g.shader_pack == *id {
|
||||
String::new()
|
||||
} else {
|
||||
id.clone()
|
||||
};
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
/// Ensure `shader_pack_config` matches `s.horizon.shader_pack`.
|
||||
pub fn sync_shader_pack_config(&mut self, s: &Settings) {
|
||||
let id = s.horizon.shader_pack.as_str();
|
||||
if id.is_empty() {
|
||||
self.shader_pack_config = None;
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.shader_pack_config
|
||||
.as_ref()
|
||||
.is_some_and(|cfg| cfg.id == id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A pack whose options can't be read has none, rather than being
|
||||
// read again every frame.
|
||||
let config = PackConfig::load(&self.shader_pack_dir(), id).unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: shader pack {id} options: {e}");
|
||||
PackConfig {
|
||||
id: id.to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
self.shader_pack_config = Some(config);
|
||||
self.shader_pack_edited = false;
|
||||
}
|
||||
/// The picked pack has options or profiles to show.
|
||||
fn shader_pack_has_settings(&self, s: &Settings) -> bool {
|
||||
self.shader_pack_config.as_ref().is_some_and(|c| {
|
||||
!s.horizon.shader_pack.is_empty()
|
||||
&& c.id == s.horizon.shader_pack
|
||||
&& (!c.options.is_empty() || !c.profiles.is_empty())
|
||||
})
|
||||
}
|
||||
fn shader_pack_dir(&self) -> std::path::PathBuf {
|
||||
if self.shader_packs.dir.as_os_str().is_empty() {
|
||||
crate::shader_pack::folder()
|
||||
} else {
|
||||
self.shader_packs.dir.clone()
|
||||
}
|
||||
}
|
||||
/// A file or folder dropped on the window: on the Shader Packs page (as
|
||||
/// Iris's `ShaderPackScreen.onFilesDrop`), a pack copied into
|
||||
/// `shaderpacks/` and picked. `None` elsewhere; the caller saves the
|
||||
/// settings and loads the pack on `Some(Ok)`.
|
||||
pub fn drop_shader_pack(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
path: &std::path::Path,
|
||||
) -> Option<Result<(), String>> {
|
||||
if self.page() != Page::ShaderPacks {
|
||||
return None;
|
||||
}
|
||||
let dir = self.shader_pack_dir();
|
||||
Some(crate::shader_pack::import_pack(path, &dir).map(|id| {
|
||||
self.shader_packs = Default::default();
|
||||
self.shader_packs.poll(dir);
|
||||
s.horizon.shader_pack = id;
|
||||
self.sync_shader_pack_config(s);
|
||||
}))
|
||||
}
|
||||
/// Change the shader pack's options, applied when its page closes.
|
||||
fn edit_shader_pack(&mut self, edit: impl FnOnce(&mut PackConfig)) {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
edit(cfg);
|
||||
self.shader_pack_edited = true;
|
||||
}
|
||||
}
|
||||
/// Save the active shader pack options to `shaderpacks/<id>.txt`.
|
||||
fn save_shader_pack_config(&self) {
|
||||
if let Some(cfg) = &self.shader_pack_config
|
||||
&& let Err(e) = cfg.save(&self.shader_pack_dir())
|
||||
{
|
||||
log::warn!("Horizon: shader pack {} options: {e}", cfg.id);
|
||||
}
|
||||
}
|
||||
pub(crate) fn shader_pack_failed(&self, id: &str) -> bool {
|
||||
self.shader_pack_error
|
||||
.as_ref()
|
||||
.is_some_and(|(e, _)| e == id)
|
||||
}
|
||||
/// Whether the Shader Packs page shows.
|
||||
fn shader_packs_shown(&self) -> bool {
|
||||
self.page() == Page::ShaderPacks
|
||||
}
|
||||
/// The graphics screen level of `page`, if it is one.
|
||||
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
|
||||
match page {
|
||||
@@ -1317,8 +1176,6 @@ impl OptionsUi {
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPackOptions => "Shader Pack Settings",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
@@ -1434,10 +1291,7 @@ impl OptionsUi {
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(open("Controller Settings...", Page::Controller), None),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
@@ -1485,16 +1339,6 @@ impl OptionsUi {
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Shader Packs..."),
|
||||
Act::Push(Page::ShaderPacks),
|
||||
),
|
||||
None,
|
||||
));
|
||||
rows.push(Row::Header("options.video.display.header"));
|
||||
rows.extend(pairs(&[
|
||||
Opt::MaxFps,
|
||||
@@ -1667,113 +1511,6 @@ impl OptionsUi {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Page::ShaderPacks => {
|
||||
let folder = &self.shader_packs;
|
||||
let mut rows = vec![Row::Note(folder.dir.display().to_string())];
|
||||
if folder.packs.is_empty() {
|
||||
rows.push(Row::Header(
|
||||
"No shader packs: put a pack's folder or .zip in this folder",
|
||||
));
|
||||
}
|
||||
for (i, (id, info)) in folder.packs.iter().enumerate() {
|
||||
let on = s.horizon.shader_pack == *id;
|
||||
let mut label = Text::literal(format!("{id}: ")).append(on_off(on));
|
||||
if info.is_err() || self.shader_pack_failed(id) {
|
||||
label = label.append(" (Failed)");
|
||||
}
|
||||
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleShaderPack(i));
|
||||
rows.push(Row::Buttons(
|
||||
if info.is_ok() {
|
||||
widget
|
||||
} else {
|
||||
inactive(widget)
|
||||
},
|
||||
None,
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
Page::ShaderPackOptions => {
|
||||
let Some(cfg) = &self.shader_pack_config else {
|
||||
return vec![Row::Header("No configurable options for this shader pack")];
|
||||
};
|
||||
let screen = cfg.current_screen();
|
||||
let mut rows = vec![Row::Note(format!("{} — {}", cfg.id, screen.label))];
|
||||
if cfg.active_screen.is_some() {
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Back to Main Screen"),
|
||||
Act::ShaderPackBackScreen,
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let mut widgets = Vec::new();
|
||||
for item in &screen.items {
|
||||
match item {
|
||||
crate::shader_pack::ScreenItem::Empty => {}
|
||||
crate::shader_pack::ScreenItem::Profile => {
|
||||
let label =
|
||||
Text::literal(format!("Profile: {}", cfg.active_profile_label()));
|
||||
widgets.push(button(0.0, 0.0, 150.0, label, Act::ShaderPackProfile));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Subscreen(sub, title) => {
|
||||
let label = Text::literal(format!("{title}..."));
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackSubscreen(sub.clone()),
|
||||
));
|
||||
}
|
||||
crate::shader_pack::ScreenItem::Option(idx) => {
|
||||
if let Some(opt) = cfg.options.get(*idx) {
|
||||
let label = Text::literal(format!(
|
||||
"{}: {}",
|
||||
opt.label,
|
||||
opt.display_value()
|
||||
));
|
||||
if let Some(value) = opt.slider_pos() {
|
||||
widgets.push(Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: 150.0,
|
||||
h: 20.0,
|
||||
kind: WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index: *idx,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
} else {
|
||||
widgets.push(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
label,
|
||||
Act::ShaderPackOption(*idx),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut iter = widgets.into_iter();
|
||||
while let Some(mut first) = iter.next() {
|
||||
if let Some(second) = iter.next() {
|
||||
rows.push(Row::Buttons(first, Some(second)));
|
||||
} else {
|
||||
first.w = 310.0;
|
||||
rows.push(Row::Buttons(first, None));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -1784,9 +1521,6 @@ impl OptionsUi {
|
||||
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec<Widget>, f32, f32, f32) {
|
||||
let mut out = Vec::new();
|
||||
let page = self.page();
|
||||
if matches!(page, Page::ShaderPacks | Page::ShaderPackOptions) {
|
||||
self.sync_shader_pack_config(s);
|
||||
}
|
||||
let cx = (w / 2.0).floor();
|
||||
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
|
||||
self.settings_search.x = cx - 100.0;
|
||||
@@ -2176,10 +1910,6 @@ impl OptionsUi {
|
||||
if matches!(page, Page::Video | Page::Controls) {
|
||||
top = 38.0;
|
||||
}
|
||||
if page == Page::ShaderPacks {
|
||||
// Two footer rows, as Iris's ShaderPackScreen.
|
||||
bottom = h - 57.0;
|
||||
}
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: if matches!(page, Page::Video | Page::Controls) {
|
||||
@@ -2266,42 +1996,13 @@ impl OptionsUi {
|
||||
}
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPackOptions => {
|
||||
Some(("Reset to Defaults", Act::ResetShaderPackOptions))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if page == Page::ShaderPacks {
|
||||
// Iris: the folder and the pack's settings above Done.
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Open Shader Pack Folder"),
|
||||
Act::OpenShaderPackFolder,
|
||||
));
|
||||
let settings = button(
|
||||
cx + 4.0,
|
||||
h - 51.0,
|
||||
150.0,
|
||||
Text::literal("Shader Pack Settings..."),
|
||||
Act::Push(Page::ShaderPackOptions),
|
||||
);
|
||||
out.push(if self.shader_pack_has_settings(s) {
|
||||
settings
|
||||
} else {
|
||||
inactive(settings)
|
||||
});
|
||||
out.push(done(h - 27.0));
|
||||
} else if let Some((label, act)) = folder {
|
||||
if page == Page::Mods {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
Text::literal("Open Mod Folder"),
|
||||
Act::OpenModFolder,
|
||||
));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
@@ -2331,9 +2032,6 @@ impl OptionsUi {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
if self.shader_packs_shown() {
|
||||
self.shader_packs.poll(crate::shader_pack::folder());
|
||||
}
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if let Some(error) = &self.resource_error {
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
|
||||
@@ -2462,16 +2160,6 @@ impl OptionsUi {
|
||||
170.0,
|
||||
));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ShaderPackOption(i),
|
||||
..
|
||||
} = &wd.kind
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*i)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, act } => {
|
||||
@@ -2512,31 +2200,6 @@ impl OptionsUi {
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::ShaderOptionSlider {
|
||||
label,
|
||||
value,
|
||||
index,
|
||||
} => {
|
||||
let dragging = self.dragging_shader_option == Some(*index);
|
||||
if hover
|
||||
&& let Some(cfg) = &self.shader_pack_config
|
||||
&& let Some(opt) = cfg.options.get(*index)
|
||||
&& !opt.description.is_empty()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
|
||||
}
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
label,
|
||||
*value,
|
||||
hover || dragging,
|
||||
true,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -2669,7 +2332,6 @@ impl OptionsUi {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenShaderPacks => self.push(Page::ShaderPacks),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2751,19 +2413,6 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
if let Some(idx) = self.dragging_shader_option
|
||||
&& let Some(wd) = widgets.iter().find(|wd| {
|
||||
matches!(&wd.kind, WKind::ShaderOptionSlider { index, .. } if *index == idx)
|
||||
})
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(idx) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
// Controller Binds: a click cancels the wait.
|
||||
@@ -2844,41 +2493,8 @@ impl OptionsUi {
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ToggleShaderPack(i) => {
|
||||
self.toggle_shader_pack(s, i);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Act::Credits => events.push(Event::Credits),
|
||||
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
|
||||
Act::ShaderPackProfile => {
|
||||
self.edit_shader_pack(|cfg| cfg.cycle_profile(false));
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ShaderPackSubscreen(sub) => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = Some(sub);
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackBackScreen => {
|
||||
if let Some(cfg) = &mut self.shader_pack_config {
|
||||
cfg.active_screen = None;
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
}
|
||||
Act::ShaderPackOption(i) => {
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(i) {
|
||||
opt.cycle(false);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::ResetShaderPackOptions => {
|
||||
self.edit_shader_pack(PackConfig::reset_all);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::None => {}
|
||||
}
|
||||
}
|
||||
@@ -2889,16 +2505,6 @@ impl OptionsUi {
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::ShaderOptionSlider { index, .. } => {
|
||||
self.dragging_shader_option = Some(index);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.edit_shader_pack(|cfg| {
|
||||
if let Some(opt) = cfg.options.get_mut(index) {
|
||||
opt.set_slider_pos(v);
|
||||
}
|
||||
});
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
events.extend(self.back(s));
|
||||
@@ -2927,7 +2533,7 @@ impl OptionsUi {
|
||||
}
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some() || self.dragging_shader_option.take().is_some();
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
vec![Event::ButtonClick]
|
||||
@@ -2983,14 +2589,6 @@ impl OptionsUi {
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
if self.page() == Page::ShaderPackOptions
|
||||
&& let Some(cfg) = &mut self.shader_pack_config
|
||||
&& cfg.active_screen.is_some()
|
||||
{
|
||||
cfg.active_screen = None;
|
||||
self.scroll = 0.0;
|
||||
return events;
|
||||
}
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
self.scroll = 0.0;
|
||||
self.scrollbar_drag_y = None;
|
||||
@@ -3007,12 +2605,6 @@ impl OptionsUi {
|
||||
events.push(Event::Language(code));
|
||||
}
|
||||
}
|
||||
// Iris's ShaderPackScreen applies its edits on Done, so the
|
||||
// pack is rebuilt once, not on every click or slider step.
|
||||
Page::ShaderPackOptions if std::mem::take(&mut self.shader_pack_edited) => {
|
||||
self.save_shader_pack_config();
|
||||
events.push(Event::ReloadShaderPack);
|
||||
}
|
||||
Page::Packs => {
|
||||
// PackSelectionScreen.onClose: PackSelectionModel.commit.
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
@@ -3337,177 +2929,6 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The Shader Packs page (Video Settings > Shader Packs..., or the
|
||||
/// Shader Pack row of Advanced graphics): the folder's path, Open Shader
|
||||
/// Pack Folder, and a switch per pack (#941, #925).
|
||||
#[test]
|
||||
fn classic_shader_packs_page_shows_the_folder_and_picks_a_pack() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shaderpacks-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let find = |ui: &mut OptionsUi, s: &Settings, act: &dyn Fn(&Act) -> bool| {
|
||||
ui.layout(s, size.0, size.1)
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
|
||||
};
|
||||
ui.show(Page::Video);
|
||||
let open = find(&mut ui, &s, &|a| *a == Act::Push(Page::ShaderPacks)).unwrap();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
open.x + 1.0,
|
||||
open.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
let path = dir.display().to_string();
|
||||
for line in [path.as_str(), "No shader packs"] {
|
||||
assert!(
|
||||
empty.iter().any(
|
||||
|w| matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(line))
|
||||
),
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
let folder = find(&mut ui, &s, &|a| *a == Act::OpenShaderPackFolder).unwrap();
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
folder.x + 1.0,
|
||||
folder.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Event::ButtonClick, Event::OpenShaderPackFolder]
|
||||
);
|
||||
|
||||
std::fs::write(dir.join("Pack.zip"), b"").unwrap();
|
||||
ui.shader_packs = Default::default();
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let pack = find(&mut ui, &s, &|a| *a == Act::ToggleShaderPack(0)).unwrap();
|
||||
let events = ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
pack.x + 1.0,
|
||||
pack.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(events.contains(&Event::Changed));
|
||||
assert_eq!(s.horizon.shader_pack, "Pack.zip");
|
||||
ui.shader_pack_error = Some(("Pack.zip".into(), "broken".into()));
|
||||
let label = |ui: &mut OptionsUi, s: &Settings| match find(ui, s, &|a| {
|
||||
*a == Act::ToggleShaderPack(0)
|
||||
})
|
||||
.unwrap()
|
||||
.kind
|
||||
{
|
||||
WKind::Button { label, .. } => label.plain(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(
|
||||
label(&mut ui, &s).ends_with("(Failed)"),
|
||||
"{}",
|
||||
label(&mut ui, &s)
|
||||
);
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
pack.x + 1.0,
|
||||
pack.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.horizon.shader_pack, "", "a second click turns it off");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_pack_is_picked_and_its_settings_apply_on_done() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-shaderpack-drop-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
ui.shader_packs.poll(dir.clone());
|
||||
let sample = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/shaderpacks/horizon-sample");
|
||||
assert_eq!(
|
||||
ui.drop_shader_pack(&mut s, &sample),
|
||||
None,
|
||||
"only on its page"
|
||||
);
|
||||
ui.show(Page::ShaderPacks);
|
||||
assert_eq!(ui.drop_shader_pack(&mut s, &sample), Some(Ok(())));
|
||||
assert_eq!(s.horizon.shader_pack, "horizon-sample");
|
||||
assert!(dir.join("horizon-sample/pack.toml").is_file());
|
||||
let again = ui.drop_shader_pack(&mut s, &sample).unwrap().unwrap_err();
|
||||
assert!(again.contains("already"), "{again}");
|
||||
|
||||
let click = |ui: &mut OptionsUi, s: &mut Settings, act: &dyn Fn(&Act) -> bool| {
|
||||
let w = ui
|
||||
.layout(s, size.0, size.1)
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
|
||||
.unwrap();
|
||||
ui.click(
|
||||
s,
|
||||
&atlas,
|
||||
w.x + 1.0,
|
||||
w.y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
)
|
||||
};
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
assert_eq!(ui.page(), Page::ShaderPackOptions);
|
||||
for _ in 0..3 {
|
||||
let events = click(&mut ui, &mut s, &|a| matches!(a, Act::ShaderPackOption(_)));
|
||||
assert!(!events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
}
|
||||
let txt = dir.join("horizon-sample.txt");
|
||||
assert!(!txt.exists(), "nothing is saved before Done");
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(events.contains(&Event::ReloadShaderPack), "{events:?}");
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
assert!(txt.is_file(), "Done saves the values");
|
||||
click(&mut ui, &mut s, &|a| {
|
||||
*a == Act::Push(Page::ShaderPackOptions)
|
||||
});
|
||||
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
|
||||
assert!(
|
||||
!events.contains(&Event::ReloadShaderPack),
|
||||
"no change: no reload"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
@@ -4618,7 +4039,6 @@ mod tests {
|
||||
[
|
||||
Page::Graphics,
|
||||
Page::GraphicsAdvanced,
|
||||
Page::ShaderPacks,
|
||||
Page::Controller,
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
@@ -4652,7 +4072,7 @@ mod tests {
|
||||
// Vanilla's own pages keep the Horizon pages they had.
|
||||
ui.stack = vec![Page::Main, Page::Video];
|
||||
let video = pushes(&mut ui);
|
||||
for page in [Page::Graphics, Page::GraphicsAdvanced, Page::ShaderPacks] {
|
||||
for page in [Page::Graphics, Page::GraphicsAdvanced] {
|
||||
assert!(video.contains(&page), "{page:?}");
|
||||
}
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
// Horizon shader pack prelude, api 1 (docs/shader-packs.md): placed before
|
||||
// every stage's source. Bindings, the Frame uniform, the full-screen vertex
|
||||
// shader and helpers that hide the renderer's reversed depth.
|
||||
struct Frame {
|
||||
view: mat4x4<f32>,
|
||||
projection: mat4x4<f32>,
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
camera_position: vec4<f32>,
|
||||
resolution: vec4<f32>,
|
||||
time: vec4<f32>,
|
||||
sun: vec4<f32>,
|
||||
moon: vec4<f32>,
|
||||
sky_color: vec4<f32>,
|
||||
fog_color: vec4<f32>,
|
||||
fog: vec4<f32>,
|
||||
weather: vec4<f32>,
|
||||
camera_state: vec4<f32>,
|
||||
view_params: vec4<f32>,
|
||||
dimension: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var scene_color: texture_2d<f32>;
|
||||
@group(0) @binding(1) var scene_depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var previous: texture_2d<f32>;
|
||||
@group(0) @binding(3) var linear_sampler: sampler;
|
||||
@group(0) @binding(4) var nearest_sampler: sampler;
|
||||
@group(0) @binding(5) var<uniform> frame: Frame;
|
||||
|
||||
struct StageIn {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn horizon_stage_vertex(@builtin(vertex_index) i: u32) -> StageIn {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: StageIn;
|
||||
out.position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The world's depth at `uv`: 1 at the near plane, 0 for the sky.
|
||||
fn depth_at(uv: vec2<f32>) -> f32 {
|
||||
return textureSampleLevel(scene_depth, nearest_sampler, uv, 0);
|
||||
}
|
||||
fn is_sky(depth: f32) -> bool {
|
||||
return depth <= 0.0;
|
||||
}
|
||||
// Blocks from the eye along the view axis.
|
||||
fn linear_depth(depth: f32) -> f32 {
|
||||
let near = frame.view_params.x;
|
||||
let far = frame.view_params.y;
|
||||
return near * far / ((far - near) * depth + near);
|
||||
}
|
||||
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
|
||||
let v = frame.inverse_projection * vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
|
||||
return v.xyz / v.w;
|
||||
}
|
||||
// World position relative to the eye (add frame.camera_position.xyz for the
|
||||
// absolute one).
|
||||
fn world_offset(uv: vec2<f32>, depth: f32) -> vec3<f32> {
|
||||
return (frame.inverse_view * vec4(view_position(uv, depth), 1.0)).xyz;
|
||||
}
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, c <= vec3(0.04045));
|
||||
}
|
||||
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
return select(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3(0.0031308));
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Horizon shader pack prelude, api 2 additions (docs/shader-packs.md): placed
|
||||
// after the api 1 prelude before the stages of an `api = 2` pack, so api 1
|
||||
// packs keep every name they may have declared themselves.
|
||||
struct FrameExt {
|
||||
previous_view: mat4x4<f32>,
|
||||
previous_projection: mat4x4<f32>,
|
||||
shadow_view: mat4x4<f32>,
|
||||
shadow_projection: mat4x4<f32>,
|
||||
shadow_inverse_view: mat4x4<f32>,
|
||||
shadow_inverse_projection: mat4x4<f32>,
|
||||
// xyz: previous camera world position, w: frame delta time (seconds)
|
||||
previous_camera: vec4<f32>,
|
||||
// x: shadow map resolution, y: shadow distance (blocks), z: 0,
|
||||
// w: 1 while the shadow map is drawn this frame, else 0
|
||||
shadow_params: vec4<f32>,
|
||||
// x: eye block light (0..240), y: eye sky light (0..240), z: smoothed
|
||||
// rain (0..1), w: 0
|
||||
environment: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(6) var<uniform> frame_ext: FrameExt;
|
||||
@group(0) @binding(7) var scene_normal: texture_2d<f32>;
|
||||
@group(0) @binding(8) var scene_material: texture_2d<f32>;
|
||||
@group(0) @binding(9) var opaque_depth: texture_depth_2d;
|
||||
@group(0) @binding(10) var shadow_depth: texture_depth_2d;
|
||||
@group(0) @binding(11) var shadow_sampler: sampler_comparison;
|
||||
@group(0) @binding(12) var noise_texture: texture_2d<f32>;
|
||||
@group(0) @binding(13) var repeat_sampler: sampler;
|
||||
|
||||
// The opaque world's depth at `uv` (before translucent water and stained glass).
|
||||
fn opaque_depth_at(uv: vec2<f32>) -> f32 {
|
||||
return textureSampleLevel(opaque_depth, nearest_sampler, uv, 0);
|
||||
}
|
||||
|
||||
// World-space surface normal at `uv` from the G-buffer (or reconstructed from depth).
|
||||
fn world_normal_at(uv: vec2<f32>) -> vec3<f32> {
|
||||
let encoded = textureSampleLevel(scene_normal, nearest_sampler, uv, 0.0);
|
||||
if encoded.w > 0.001 {
|
||||
return normalize(encoded.xyz * 2.0 - 1.0);
|
||||
}
|
||||
let step_uv = frame.resolution.zw;
|
||||
let d0 = depth_at(uv);
|
||||
let p0 = world_offset(uv, d0);
|
||||
let px = world_offset(uv + vec2(step_uv.x, 0.0), depth_at(uv + vec2(step_uv.x, 0.0))) - p0;
|
||||
let py = world_offset(uv + vec2(0.0, step_uv.y), depth_at(uv + vec2(0.0, step_uv.y))) - p0;
|
||||
let n = cross(py, px);
|
||||
let len = length(n);
|
||||
if len > 1e-6 {
|
||||
return n / len;
|
||||
}
|
||||
return vec3(0.0, 1.0, 0.0);
|
||||
}
|
||||
// G-buffer material data at `uv`: r = block light (0..1), g = sky light (0..1),
|
||||
// b = water/translucent mask (0..1), a = roughness (0..1).
|
||||
fn material_at(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(scene_material, nearest_sampler, uv, 0.0);
|
||||
}
|
||||
// Reproject a camera-relative world position into previous-frame UV and clip depth.
|
||||
fn reproject_uv(world_rel: vec3<f32>) -> vec3<f32> {
|
||||
let camera_delta = frame.camera_position.xyz - frame_ext.previous_camera.xyz;
|
||||
let prev_rel = world_rel + camera_delta;
|
||||
let clip = frame_ext.previous_projection * (frame_ext.previous_view * vec4(prev_rel, 1.0));
|
||||
if clip.w <= 1e-5 {
|
||||
return vec3(-1.0);
|
||||
}
|
||||
let ndc = clip.xyz / clip.w;
|
||||
return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z);
|
||||
}
|
||||
// Sample the directional sun/moon shadow map with 5-tap PCF and normal offset bias.
|
||||
// Returns 1.0 in full light and 0.0 in full shadow.
|
||||
fn sample_shadow(world_rel: vec3<f32>, world_normal: vec3<f32>) -> f32 {
|
||||
if frame_ext.shadow_params.w <= 0.001 {
|
||||
return 1.0;
|
||||
}
|
||||
let dist = length(world_rel);
|
||||
let max_dist = max(frame_ext.shadow_params.y, 16.0);
|
||||
if dist >= max_dist {
|
||||
return 1.0;
|
||||
}
|
||||
let light_dir = normalize(select(frame.moon.xyz, frame.sun.xyz, frame.sun.y >= -0.05));
|
||||
let ndotl = max(dot(world_normal, light_dir), 0.0);
|
||||
let bias_pos = world_rel + world_normal * (0.06 + 0.12 * (1.0 - ndotl));
|
||||
let light_clip = frame_ext.shadow_projection * (frame_ext.shadow_view * vec4(bias_pos, 1.0));
|
||||
let ndc = light_clip.xyz / max(light_clip.w, 1e-5);
|
||||
let shadow_uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
if any(shadow_uv < vec2(0.002)) || any(shadow_uv > vec2(0.998)) || ndc.z <= 0.0 || ndc.z >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let texel = 1.0 / max(frame_ext.shadow_params.x, 512.0);
|
||||
let ref_z = clamp(ndc.z + 0.0008, 0.0, 1.0);
|
||||
var sum = textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv, ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(texel, 0.0), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(0.0, texel), ref_z);
|
||||
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(0.0, texel), ref_z);
|
||||
let fade = smoothstep(max_dist, max_dist * 0.82, dist);
|
||||
return mix(1.0, sum * 0.2, fade * frame_ext.shadow_params.w);
|
||||
}
|
||||
|
||||
// ACES fitted filmic tonemapping curve (linear HDR -> linear 0..1).
|
||||
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||
let a = 2.51;
|
||||
let b = 0.03;
|
||||
let c = 2.43;
|
||||
let d = 0.59;
|
||||
let e = 0.14;
|
||||
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0));
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "horizon-shaderpack"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Java (OptiFine/Iris) shader packs for Horizon: Iris's preprocessor and the GLSL to WGSL translation"
|
||||
|
||||
[dependencies]
|
||||
glsl-lang = "0.8.1"
|
||||
naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in", "wgsl-out"] }
|
||||
# The C preprocessor naga's GLSL front end uses.
|
||||
pp-rs = "0.2.1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
@@ -1,198 +0,0 @@
|
||||
//! `#include` the way Iris resolves it (`IncludeGraph`, `IncludeProcessor`):
|
||||
//! before any other directive, so include guards don't apply; `/lib/x.glsl`
|
||||
//! from `shaders/`, `x.glsl` from the including file's folder. Every file
|
||||
//! reachable from the programs is read once into a graph, and a program is
|
||||
//! the graph inlined from its file.
|
||||
|
||||
use crate::source::PackFiles;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// One file of the graph: its lines and the include on each `#include` line.
|
||||
pub struct FileNode {
|
||||
pub lines: Vec<String>,
|
||||
/// Line index → included file (a normalised path relative to `shaders/`).
|
||||
pub includes: BTreeMap<usize, String>,
|
||||
}
|
||||
|
||||
pub struct IncludeGraph {
|
||||
pub files: BTreeMap<String, FileNode>,
|
||||
/// Files that couldn't be read or include themselves, with the reason.
|
||||
pub failures: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// A program after inlining: the source and, for each of its lines, the
|
||||
/// file and 1-based line it came from (to report errors where they are).
|
||||
pub struct Inlined {
|
||||
pub text: String,
|
||||
pub origin: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
impl Inlined {
|
||||
/// `file:line` of 1-based `line` of the inlined text.
|
||||
pub fn locate(&self, line: u32) -> String {
|
||||
match self.origin.get(line.saturating_sub(1) as usize) {
|
||||
Some((file, at)) => format!("{file}:{at}"),
|
||||
None => format!("line {line}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `target` from an `#include` in `from`, as a path relative to `shaders/`
|
||||
/// with `.` and `..` resolved (Iris's `AbsolutePackPath`).
|
||||
pub fn resolve(from: &str, target: &str) -> String {
|
||||
let joined = match target.strip_prefix('/') {
|
||||
Some(absolute) => absolute.to_owned(),
|
||||
None => match from.rsplit_once('/') {
|
||||
Some((dir, _)) => format!("{dir}/{target}"),
|
||||
None => target.to_owned(),
|
||||
},
|
||||
};
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for segment in joined.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
s => parts.push(s),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// The target of an `#include` line, or `None` for any other line.
|
||||
fn include_target(line: &str) -> Option<&str> {
|
||||
let t = line.trim();
|
||||
if !t.starts_with("#include") {
|
||||
return None;
|
||||
}
|
||||
let target = t.get("#include ".len()..).unwrap_or("").trim();
|
||||
let target = target.strip_prefix('"').unwrap_or(target);
|
||||
Some(target.strip_suffix('"').unwrap_or(target))
|
||||
}
|
||||
|
||||
impl IncludeGraph {
|
||||
/// Read `starts` and every file they include, transitively.
|
||||
pub fn build(files: &PackFiles, starts: &[String]) -> Self {
|
||||
let mut graph = Self {
|
||||
files: BTreeMap::new(),
|
||||
failures: BTreeMap::new(),
|
||||
};
|
||||
let mut seen: BTreeSet<String> = starts.iter().cloned().collect();
|
||||
let mut queue: Vec<(String, Option<(String, usize)>)> =
|
||||
starts.iter().map(|s| (s.clone(), None)).collect();
|
||||
while let Some((path, from)) = queue.pop() {
|
||||
let Some(text) = files.read(&path) else {
|
||||
let why = match from {
|
||||
Some((file, line)) => {
|
||||
format!("{file}:{}: #include \"{path}\": file not found", line + 1)
|
||||
}
|
||||
None => format!("{path}: file not found"),
|
||||
};
|
||||
graph.failures.insert(path, why);
|
||||
continue;
|
||||
};
|
||||
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
|
||||
let includes: BTreeMap<usize, String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, l)| Some((i, resolve(&path, include_target(l)?))))
|
||||
.collect();
|
||||
if let Some((line, _)) = includes.iter().find(|(_, target)| **target == path) {
|
||||
graph.failures.insert(
|
||||
path.clone(),
|
||||
format!("{path}:{}: file includes itself", line + 1),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (&line, target) in &includes {
|
||||
if seen.insert(target.clone()) {
|
||||
queue.push((target.clone(), Some((path.clone(), line))));
|
||||
}
|
||||
}
|
||||
graph.files.insert(path, FileNode { lines, includes });
|
||||
}
|
||||
graph
|
||||
}
|
||||
|
||||
/// The graph's weakly connected components (files linked by includes in
|
||||
/// either direction), each sorted. Iris discovers options per component.
|
||||
pub fn components(&self) -> Vec<Vec<String>> {
|
||||
let names: Vec<&String> = self.files.keys().collect();
|
||||
let index: BTreeMap<&str, usize> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.as_str(), i))
|
||||
.collect();
|
||||
let mut parent: Vec<usize> = (0..names.len()).collect();
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
for (i, node) in self.files.values().enumerate() {
|
||||
for target in node.includes.values() {
|
||||
if let Some(&j) = index.get(target.as_str()) {
|
||||
let (a, b) = (root(&mut parent, i), root(&mut parent, j));
|
||||
parent[a] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<usize, Vec<String>> = BTreeMap::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let r = root(&mut parent, i);
|
||||
groups.entry(r).or_default().push((*name).clone());
|
||||
}
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
/// Inline `start`, passing every line that isn't an `#include` through
|
||||
/// `edit(file, line index, text)` first (Iris applies option values there).
|
||||
pub fn inline(
|
||||
&self,
|
||||
start: &str,
|
||||
edit: &dyn Fn(&str, usize, &str) -> String,
|
||||
) -> Result<Inlined, String> {
|
||||
let mut out = Inlined {
|
||||
text: String::new(),
|
||||
origin: Vec::new(),
|
||||
};
|
||||
let mut stack = Vec::new();
|
||||
self.inline_into(start, edit, &mut stack, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn inline_into(
|
||||
&self,
|
||||
path: &str,
|
||||
edit: &dyn Fn(&str, usize, &str) -> String,
|
||||
stack: &mut Vec<String>,
|
||||
out: &mut Inlined,
|
||||
) -> Result<(), String> {
|
||||
if let Some(why) = self.failures.get(path) {
|
||||
return Err(why.clone());
|
||||
}
|
||||
if stack.iter().any(|p| p == path) {
|
||||
return Err(format!("#include cycle: {} -> {path}", stack.join(" -> ")));
|
||||
}
|
||||
let node = self
|
||||
.files
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("{path}: file not found"))?;
|
||||
stack.push(path.to_owned());
|
||||
for (i, line) in node.lines.iter().enumerate() {
|
||||
match node.includes.get(&i) {
|
||||
Some(target) => self.inline_into(target, edit, stack, out)?,
|
||||
None => {
|
||||
out.text.push_str(&edit(path, i, line));
|
||||
out.text.push('\n');
|
||||
out.origin.push((path.to_owned(), i as u32 + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
//! Java shader packs (OptiFine/Iris) for Horizon (docs/shader-packs.md,
|
||||
//! "Java shader packs"). This crate does the part that needs no GPU: open a
|
||||
//! pack, find its programs and options, and turn a program into plain GLSL
|
||||
//! the way Iris does before compiling it (`#include`s inlined, the player's
|
||||
//! option values written into the source, Iris's macros expanded), read its
|
||||
//! `shaders.properties`, and translate programs to WGSL.
|
||||
|
||||
pub mod include;
|
||||
pub mod options;
|
||||
pub mod pipeline;
|
||||
pub mod preprocess;
|
||||
pub mod properties;
|
||||
pub mod source;
|
||||
pub mod translate;
|
||||
pub mod uniforms;
|
||||
|
||||
use include::{IncludeGraph, Inlined};
|
||||
use options::{Annotated, OptionSet, OptionValues};
|
||||
use preprocess::{Environment, Preprocessed};
|
||||
use properties::{Properties, ShaderProperties};
|
||||
use source::PackFiles;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
use translate::{Link, Translated};
|
||||
|
||||
/// A program's pipeline stage, from its file extension.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
Vertex,
|
||||
Fragment,
|
||||
Geometry,
|
||||
Compute,
|
||||
TessControl,
|
||||
TessEval,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
/// The stage of `composite1.fsh` and the like; `None` for other files.
|
||||
pub fn of(program: &str) -> Option<Self> {
|
||||
Some(match program.rsplit_once('.')?.1 {
|
||||
"vsh" => Self::Vertex,
|
||||
"fsh" => Self::Fragment,
|
||||
"gsh" => Self::Geometry,
|
||||
"csh" => Self::Compute,
|
||||
"tcs" => Self::TessControl,
|
||||
"tes" => Self::TessEval,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An opened Java shader pack.
|
||||
pub struct JavaPack {
|
||||
pub files: PackFiles,
|
||||
/// Every program source file (`composite1.fsh`, `world-1/final.vsh`).
|
||||
pub programs: Vec<String>,
|
||||
pub graph: IncludeGraph,
|
||||
annotations: BTreeMap<String, Annotated>,
|
||||
/// The options the player can set (`ShaderPackOptions`).
|
||||
pub options: OptionSet,
|
||||
}
|
||||
|
||||
impl JavaPack {
|
||||
/// Open the pack folder or zip at `path` and read every program and file
|
||||
/// they include.
|
||||
pub fn open(path: &Path) -> Result<Self, String> {
|
||||
let files = PackFiles::open(path)?;
|
||||
let programs = files.programs();
|
||||
if programs.is_empty() {
|
||||
return Err("no programs in shaders/".into());
|
||||
}
|
||||
let graph = IncludeGraph::build(&files, &programs);
|
||||
let annotations: BTreeMap<String, Annotated> = graph
|
||||
.files
|
||||
.iter()
|
||||
.map(|(path, node)| (path.clone(), Annotated::parse(&node.lines)))
|
||||
.collect();
|
||||
// A boolean #define is an option when an #ifdef in its component
|
||||
// refers to it.
|
||||
let mut options = OptionSet::default();
|
||||
for component in graph.components() {
|
||||
let refs: BTreeSet<&String> = component
|
||||
.iter()
|
||||
.flat_map(|f| &annotations[f].ifdef_refs)
|
||||
.collect();
|
||||
for file in &component {
|
||||
let a = &annotations[file];
|
||||
a.bools
|
||||
.values()
|
||||
.filter(|o| refs.contains(&o.name))
|
||||
.for_each(|o| options.add_bool(o));
|
||||
a.strings.values().for_each(|o| options.add_string(o));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
files,
|
||||
programs,
|
||||
graph,
|
||||
annotations,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
/// The player's values from saved `name=value` pairs (`shaderpacks/<pack>.txt`).
|
||||
pub fn values(&self, saved: &BTreeMap<String, String>) -> OptionValues {
|
||||
OptionValues::new(&self.options, saved)
|
||||
}
|
||||
|
||||
/// `program` with its includes inlined and `values` applied.
|
||||
pub fn source(&self, program: &str, values: &OptionValues) -> Result<Inlined, String> {
|
||||
self.graph
|
||||
.inline(program, &|file, i, line| match self.annotations.get(file) {
|
||||
Some(a) => a.edit(values, i, line),
|
||||
None => line.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `program` as plain GLSL: [`JavaPack::source`], then the preprocessor.
|
||||
pub fn preprocess(
|
||||
&self,
|
||||
program: &str,
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
) -> Result<Preprocessed, String> {
|
||||
preprocess::preprocess(&self.source(program, values)?, env)
|
||||
}
|
||||
|
||||
/// `program` translated to WGSL. The programs of one pass (its `.vsh`,
|
||||
/// then its `.fsh`) share `link`.
|
||||
pub fn translate(
|
||||
&self,
|
||||
program: &str,
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
link: &mut Link,
|
||||
) -> Result<Translated, String> {
|
||||
let stage = Stage::of(program).ok_or_else(|| format!("{program}: not a program"))?;
|
||||
let source = self.source(program, values)?;
|
||||
let pre = preprocess::preprocess(&source, env)?;
|
||||
translate::translate(stage, &pre.text, link, &|line| source.locate(line))
|
||||
.map_err(|e| format!("{program}: {e}"))
|
||||
}
|
||||
|
||||
/// The pack's `shaders.properties` with `values` (empty when it has none).
|
||||
pub fn properties(
|
||||
&self,
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
) -> Result<ShaderProperties, String> {
|
||||
match self.files.read("shaders.properties") {
|
||||
Some(text) => ShaderProperties::new(&text, env, &self.options, values),
|
||||
None => Ok(ShaderProperties::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's language translations (`shaders/lang/<code_lower>.lang` or
|
||||
/// `en_US.lang`), mapping keys such as `option.SHADOW_QUALITY`,
|
||||
/// `value.SHADOW_QUALITY.2`, `screen.LIGHTING`, and `profile.HIGH` to their
|
||||
/// display labels.
|
||||
pub fn translations(&self, lang_code: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
let lower = lang_code.to_lowercase();
|
||||
let upper = match lower.split_once('_') {
|
||||
Some((l, r)) => format!("{l}_{}", r.to_uppercase()),
|
||||
None => lower.clone(),
|
||||
};
|
||||
let mut candidates = vec!["lang/en_US.lang".to_owned(), "lang/en_us.lang".to_owned()];
|
||||
if lower != "en_us" {
|
||||
candidates.push(format!("lang/{lower}.lang"));
|
||||
candidates.push(format!("lang/{upper}.lang"));
|
||||
}
|
||||
for path in candidates {
|
||||
if let Some(text) = self.files.read(&path) {
|
||||
for (k, v) in Properties::parse(&text).iter() {
|
||||
out.insert(k.to_owned(), v.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl JavaPack {
|
||||
/// The block ids of the pack's `block.properties` (empty without one),
|
||||
/// [`properties::block_ids`].
|
||||
pub fn block_ids(
|
||||
&self,
|
||||
values: &OptionValues,
|
||||
env: &Environment,
|
||||
) -> Result<BTreeMap<String, i32>, String> {
|
||||
match self.files.read("block.properties") {
|
||||
Some(text) => properties::block_ids(&text, env, &self.options, values)
|
||||
.map_err(|e| format!("block.properties: {e}")),
|
||||
None => Ok(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A program's name in `shaders.properties` (`world-1/composite1.fsh` is
|
||||
/// `world-1/composite1`).
|
||||
pub fn program_name(program: &str) -> &str {
|
||||
program.rsplit_once('.').map_or(program, |(name, _)| name)
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
//! A pack's options, found in its source the way Iris finds them
|
||||
//! (`OptionAnnotatedSource`, `ParsedString`, `OptionSet`), and the player's
|
||||
//! values written back into the lines they came from.
|
||||
//!
|
||||
//! - `#define NAME` / `//#define NAME` (optionally `// comment`): a boolean,
|
||||
//! on unless commented out, and an option only if some `#ifdef NAME` /
|
||||
//! `#ifndef NAME` in the same include component refers to it.
|
||||
//! - `#define NAME VALUE // comment [A B C]`: a value with its allowed list.
|
||||
//! - `const int|float|bool NAME = VALUE; // [A B C]`: only for the constants
|
||||
//! Iris lets players configure ([`CONST_OPTIONS`]).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// The `const` names Iris configures (`VALID_CONST_OPTION_NAMES`), apart from
|
||||
/// the per-buffer ones [`is_const_option`] adds.
|
||||
pub const CONST_OPTIONS: &[&str] = &[
|
||||
"shadowMapResolution",
|
||||
"shadowDistance",
|
||||
"voxelDistance",
|
||||
"shadowDistanceRenderMul",
|
||||
"entityShadowDistanceMul",
|
||||
"shadowIntervalSize",
|
||||
"generateShadowMipmap",
|
||||
"generateShadowColorMipmap",
|
||||
"shadowHardwareFiltering",
|
||||
"shadowtex0Mipmap",
|
||||
"shadowtexMipmap",
|
||||
"shadowtex1Mipmap",
|
||||
"shadowtex0Nearest",
|
||||
"shadowtexNearest",
|
||||
"shadow0MinMagNearest",
|
||||
"shadowtex1Nearest",
|
||||
"shadow1MinMagNearest",
|
||||
"wetnessHalflife",
|
||||
"drynessHalflife",
|
||||
"eyeBrightnessHalflife",
|
||||
"centerDepthHalflife",
|
||||
"sunPathRotation",
|
||||
"ambientOcclusionLevel",
|
||||
"superSamplingLevel",
|
||||
"noiseTextureResolution",
|
||||
];
|
||||
|
||||
/// Iris's shadow colour buffer count (`MAX_SHADOW_COLOR_BUFFERS_IRIS`).
|
||||
const SHADOW_COLOR_BUFFERS: u32 = 8;
|
||||
|
||||
pub fn is_const_option(name: &str) -> bool {
|
||||
CONST_OPTIONS.contains(&name)
|
||||
|| (0..SHADOW_COLOR_BUFFERS).any(|i| {
|
||||
[
|
||||
format!("shadowcolor{i}Mipmap"),
|
||||
format!("shadowColor{i}Mipmap"),
|
||||
format!("shadowcolor{i}Nearest"),
|
||||
format!("shadowColor{i}Nearest"),
|
||||
format!("shadowcolor{i}MinMagNearest"),
|
||||
format!("shadowColor{i}MinMagNearest"),
|
||||
format!("shadowHardwareFiltering{i}"),
|
||||
]
|
||||
.iter()
|
||||
.any(|n| n == name)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum OptionKind {
|
||||
Define,
|
||||
Const,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BoolOption {
|
||||
pub kind: OptionKind,
|
||||
pub name: String,
|
||||
pub comment: Option<String>,
|
||||
pub default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StringOption {
|
||||
pub kind: OptionKind,
|
||||
pub name: String,
|
||||
pub comment: Option<String>,
|
||||
pub default: String,
|
||||
/// The bracketed list, with the default added at the end when missing.
|
||||
pub allowed: Vec<String>,
|
||||
}
|
||||
|
||||
impl StringOption {
|
||||
/// `StringOption.create`: `None` without a `[...]` list in the comment.
|
||||
fn create(kind: OptionKind, name: &str, comment: Option<&str>, default: &str) -> Option<Self> {
|
||||
let comment = comment?;
|
||||
let open = comment.find('[')?;
|
||||
let close = open + comment[open..].find(']')?;
|
||||
// Java's split(" "): empty strings between doubled spaces stay,
|
||||
// trailing ones go.
|
||||
let mut allowed: Vec<String> = comment[open + 1..close]
|
||||
.split(' ')
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
while allowed.last().is_some_and(String::is_empty) {
|
||||
allowed.pop();
|
||||
}
|
||||
if !allowed.iter().any(|v| v == default) {
|
||||
allowed.push(default.to_owned());
|
||||
}
|
||||
let rest = format!("{}{}", &comment[..open], &comment[close + 1..]);
|
||||
Some(Self {
|
||||
kind,
|
||||
name: name.to_owned(),
|
||||
comment: non_empty(&rest),
|
||||
default: default.to_owned(),
|
||||
allowed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An option's comment; empty is none (`BaseOption`).
|
||||
fn non_empty(comment: &str) -> Option<String> {
|
||||
let c = comment.trim();
|
||||
(!c.is_empty()).then(|| c.to_owned())
|
||||
}
|
||||
|
||||
/// Iris's `ParsedString`: a cursor over a trimmed line.
|
||||
struct Cursor(Vec<char>);
|
||||
|
||||
impl Cursor {
|
||||
fn new(s: &str) -> Self {
|
||||
Self(s.chars().collect())
|
||||
}
|
||||
fn literal(&mut self, token: &str) -> bool {
|
||||
let t: Vec<char> = token.chars().collect();
|
||||
if self.0.starts_with(&t) {
|
||||
self.0.drain(..t.len());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whitespace first, then trims both ends (as Iris does).
|
||||
fn whitespace(&mut self) -> bool {
|
||||
if !self.0.first().is_some_and(|c| c.is_whitespace()) {
|
||||
return false;
|
||||
}
|
||||
let s: String = self.0.iter().collect();
|
||||
self.0 = s.trim().chars().collect();
|
||||
true
|
||||
}
|
||||
/// `//` and any further slashes.
|
||||
fn comments(&mut self) -> bool {
|
||||
if !self.literal("//") {
|
||||
return false;
|
||||
}
|
||||
while self.literal("/") {}
|
||||
true
|
||||
}
|
||||
fn contains(&self, s: &str) -> bool {
|
||||
self.rest().contains(s)
|
||||
}
|
||||
fn is_end(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
fn rest(&self) -> String {
|
||||
self.0.iter().collect()
|
||||
}
|
||||
fn take(&mut self, n: usize) -> String {
|
||||
self.0.drain(..n).collect()
|
||||
}
|
||||
fn word(&mut self) -> Option<String> {
|
||||
let n = self
|
||||
.0
|
||||
.iter()
|
||||
.take_while(|c| c.is_alphanumeric() || **c == '_')
|
||||
.count();
|
||||
(n > 0).then(|| self.take(n))
|
||||
}
|
||||
/// Iris's `takeNumber`: digits and any character next to a digit, an `f`
|
||||
/// suffix, and the whole must parse as a Java float.
|
||||
fn number(&mut self) -> Option<String> {
|
||||
let c = &self.0;
|
||||
let mut n = 0;
|
||||
while n < c.len() {
|
||||
if n + 1 < c.len() {
|
||||
if !c[n].is_ascii_digit() && !c[n + 1].is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
} else if !c[n].is_ascii_digit() {
|
||||
break;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 && n + 1 < c.len() && (c[n] == 'f' || c[n] == 'F') {
|
||||
n += 1;
|
||||
}
|
||||
let text: String = c[..n].iter().collect();
|
||||
java_float(&text).then(|| self.take(n))
|
||||
}
|
||||
fn word_or_number(&mut self) -> Option<String> {
|
||||
self.number().or_else(|| self.word())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Java's `Float.parseFloat` accepts `s` (decimal forms; a trailing
|
||||
/// `f`/`d` is allowed).
|
||||
fn java_float(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
let s = s.strip_suffix(['f', 'F', 'd', 'D']).unwrap_or(s);
|
||||
!s.is_empty() && s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// The options and `#ifdef` references of one file, by line index.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Annotated {
|
||||
pub bools: BTreeMap<usize, BoolOption>,
|
||||
pub strings: BTreeMap<usize, StringOption>,
|
||||
/// Names tested by a bare `#ifdef NAME` / `#ifndef NAME` line.
|
||||
pub ifdef_refs: BTreeSet<String>,
|
||||
/// Why a line that looks like an option isn't one.
|
||||
pub diagnostics: BTreeMap<usize, String>,
|
||||
}
|
||||
|
||||
impl Annotated {
|
||||
pub fn parse(lines: &[String]) -> Self {
|
||||
let mut out = Self::default();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !["#define", "const", "#ifdef", "#ifndef"]
|
||||
.iter()
|
||||
.any(|k| line.contains(k))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut c = Cursor::new(line.trim());
|
||||
if c.literal("#ifdef") || c.literal("#ifndef") {
|
||||
if !c.whitespace() {
|
||||
continue;
|
||||
}
|
||||
let name = c.word();
|
||||
c.whitespace();
|
||||
if let (Some(name), true) = (name, c.is_end()) {
|
||||
out.ifdef_refs.insert(name);
|
||||
}
|
||||
} else if c.literal("const") {
|
||||
if let Err(why) = out.parse_const(i, &mut c) {
|
||||
out.diagnostics.insert(i, why.into());
|
||||
}
|
||||
} else if c.contains("#define") {
|
||||
if let Err(why) = out.parse_define(i, &mut c) {
|
||||
out.diagnostics.insert(i, why.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_const(&mut self, i: usize, c: &mut Cursor) -> Result<(), &'static str> {
|
||||
if !c.whitespace() {
|
||||
return Err("expected whitespace after const");
|
||||
}
|
||||
let is_bool = if c.literal("int") || c.literal("float") {
|
||||
false
|
||||
} else if c.literal("bool") {
|
||||
true
|
||||
} else {
|
||||
return Err("only int, float and bool consts are options");
|
||||
};
|
||||
if !c.whitespace() {
|
||||
return Err("expected whitespace after the type");
|
||||
}
|
||||
let name = c.word().ok_or("expected a name after the type")?;
|
||||
c.whitespace();
|
||||
if !c.literal("=") {
|
||||
return Err("expected = after the name");
|
||||
}
|
||||
c.whitespace();
|
||||
let value = c
|
||||
.word_or_number()
|
||||
.ok_or("expected a word or number after =")?;
|
||||
c.whitespace();
|
||||
if !c.literal(";") {
|
||||
return Err("expected ; after the value");
|
||||
}
|
||||
c.whitespace();
|
||||
let comment = if c.comments() {
|
||||
non_empty(&c.rest())
|
||||
} else if !c.is_end() {
|
||||
return Err("only a comment may follow the ;");
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !is_const_option(&name) {
|
||||
return Err("not one of the configurable consts");
|
||||
}
|
||||
if is_bool {
|
||||
let default = match value.as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("a bool const option must be true or false"),
|
||||
};
|
||||
self.bools.insert(
|
||||
i,
|
||||
BoolOption {
|
||||
kind: OptionKind::Const,
|
||||
name,
|
||||
comment,
|
||||
default,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let option = StringOption::create(OptionKind::Const, &name, comment.as_deref(), &value)
|
||||
.ok_or("a const option needs a [...] list of values in its comment")?;
|
||||
self.strings.insert(i, option);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_define(&mut self, i: usize, c: &mut Cursor) -> Result<(), &'static str> {
|
||||
let commented = c.comments();
|
||||
c.whitespace();
|
||||
if !c.literal("#define") {
|
||||
return Err("#define isn't where an option's would be");
|
||||
}
|
||||
if !c.whitespace() {
|
||||
return Err("expected whitespace after #define");
|
||||
}
|
||||
let name = c.word().ok_or("expected a name after #define")?;
|
||||
let mut spaced = c.whitespace();
|
||||
if c.is_end() {
|
||||
self.bools.insert(
|
||||
i,
|
||||
BoolOption {
|
||||
kind: OptionKind::Define,
|
||||
name,
|
||||
comment: None,
|
||||
default: !commented,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if c.comments() {
|
||||
let comment = non_empty(&c.rest());
|
||||
self.bools.insert(
|
||||
i,
|
||||
BoolOption {
|
||||
kind: OptionKind::Define,
|
||||
name,
|
||||
comment,
|
||||
default: !commented,
|
||||
},
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !spaced {
|
||||
return Err("only letters, digits and _ may be in an option's name");
|
||||
}
|
||||
if commented {
|
||||
return Err("only boolean #define options may be commented out");
|
||||
}
|
||||
let value = c
|
||||
.word_or_number()
|
||||
.ok_or("the value is neither a number nor a word")?;
|
||||
spaced = c.whitespace();
|
||||
if c.is_end() {
|
||||
return Err("a value #define needs a comment with a [...] list of values");
|
||||
}
|
||||
if !c.comments() {
|
||||
return Err(if spaced {
|
||||
"only a comment may follow the value"
|
||||
} else {
|
||||
"unexpected characters after the value"
|
||||
});
|
||||
}
|
||||
let comment = c.rest().trim().to_owned();
|
||||
let option = StringOption::create(OptionKind::Define, &name, Some(&comment), &value)
|
||||
.ok_or("a value #define needs a [...] list of values in its comment")?;
|
||||
self.strings.insert(i, option);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Line `i` (text `line`) with the player's values applied
|
||||
/// (`OptionAnnotatedSource.edit`).
|
||||
pub fn edit(&self, values: &OptionValues, i: usize, line: &str) -> String {
|
||||
if let Some(o) = self.bools.get(&i) {
|
||||
let value = values.bools.get(&o.name).copied();
|
||||
return match o.kind {
|
||||
OptionKind::Define => {
|
||||
let on = value.unwrap_or(o.default);
|
||||
let commented = line.trim().starts_with("//");
|
||||
if commented && on {
|
||||
let mut c = Cursor::new(line);
|
||||
c.whitespace();
|
||||
c.comments();
|
||||
c.rest()
|
||||
} else if !on {
|
||||
format!("//{line}")
|
||||
} else {
|
||||
line.to_owned()
|
||||
}
|
||||
}
|
||||
OptionKind::Const => match value {
|
||||
Some(v) => edit_const(line, &o.default.to_string(), &v.to_string()),
|
||||
None => line.to_owned(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if let Some(o) = self.strings.get(&i) {
|
||||
if let Some(v) = values.strings.get(&o.name) {
|
||||
return match o.kind {
|
||||
OptionKind::Define => format!(
|
||||
"#define {} {v} // OptionAnnotatedSource: Changed option",
|
||||
o.name
|
||||
),
|
||||
OptionKind::Const => edit_const(line, &o.default, v),
|
||||
};
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `current` after the `=` replaced by `new`.
|
||||
fn edit_const(line: &str, current: &str, new: &str) -> String {
|
||||
match line.find('=') {
|
||||
Some(eq) => format!("{}{}", &line[..eq], line[eq..].replacen(current, new, 1)),
|
||||
None => line.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every option of a pack by name. Two definitions with different defaults
|
||||
/// make the name ambiguous: Iris drops it.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct OptionSet {
|
||||
pub bools: BTreeMap<String, BoolOption>,
|
||||
pub strings: BTreeMap<String, StringOption>,
|
||||
}
|
||||
|
||||
impl OptionSet {
|
||||
pub fn add_bool(&mut self, o: &BoolOption) {
|
||||
match self.bools.get_mut(&o.name) {
|
||||
Some(existing) if existing.default != o.default => {
|
||||
self.bools.remove(&o.name);
|
||||
}
|
||||
Some(existing) => {
|
||||
if existing.comment.is_none() {
|
||||
*existing = o.clone();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.bools.insert(o.name.clone(), o.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_string(&mut self, o: &StringOption) {
|
||||
match self.strings.get_mut(&o.name) {
|
||||
Some(existing) if existing.default != o.default => {
|
||||
self.strings.remove(&o.name);
|
||||
}
|
||||
Some(existing) => {
|
||||
if existing.comment.is_none() {
|
||||
*existing = o.clone();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.strings.insert(o.name.clone(), o.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's values that differ from the defaults (`MutableOptionValues`).
|
||||
#[derive(Default, Debug, Clone, PartialEq)]
|
||||
pub struct OptionValues {
|
||||
pub bools: BTreeMap<String, bool>,
|
||||
pub strings: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl OptionValues {
|
||||
/// From saved `name=value` pairs. A boolean other than `true`/`false` is
|
||||
/// ignored; a value outside an option's allowed list is kept (profiles and
|
||||
/// hand-edited files may set one, as in OptiFine).
|
||||
pub fn new(options: &OptionSet, saved: &BTreeMap<String, String>) -> Self {
|
||||
let mut out = Self::default();
|
||||
for (name, o) in &options.bools {
|
||||
let value = match saved.get(name).map(String::as_str) {
|
||||
Some("true") => true,
|
||||
Some("false") => false,
|
||||
_ => continue,
|
||||
};
|
||||
if value != o.default {
|
||||
out.bools.insert(name.clone(), value);
|
||||
}
|
||||
}
|
||||
// A saved value the option doesn't list (an old or hand-edited
|
||||
// file) is dropped, as OptiFine's `ShaderOption.setValue` refuses it.
|
||||
for (name, o) in &options.strings {
|
||||
if let Some(v) = saved.get(name)
|
||||
&& *v != o.default
|
||||
&& o.allowed.contains(v)
|
||||
{
|
||||
out.strings.insert(name.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn one(line: &str) -> Annotated {
|
||||
Annotated::parse(&[line.to_owned()])
|
||||
}
|
||||
|
||||
fn string(line: &str) -> Option<(String, String, Vec<String>)> {
|
||||
one(line)
|
||||
.strings
|
||||
.into_values()
|
||||
.next()
|
||||
.map(|o| (o.name, o.default, o.allowed))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_defines_need_an_allowed_list() {
|
||||
// Iris's OptionDiscoverTest cases.
|
||||
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
string("#define SECRET 0 //[0]"),
|
||||
Some(("SECRET".into(), "0".into(), s(&["0"])))
|
||||
);
|
||||
assert_eq!(
|
||||
string("#define SECRET 0//[0]"),
|
||||
Some(("SECRET".into(), "0".into(), s(&["0"])))
|
||||
);
|
||||
assert_eq!(
|
||||
string("#define OPT 1 //[0]"),
|
||||
Some(("OPT".into(), "1".into(), s(&["0", "1"])))
|
||||
);
|
||||
for none in [
|
||||
"#define PI 3.14 // It's PI.",
|
||||
"#define PI 3.14",
|
||||
"const int noiseTextureResolution = 512;",
|
||||
"const int noiseTextureResolution = 512; // Default noise texture size [",
|
||||
] {
|
||||
let a = one(none);
|
||||
assert!(a.strings.is_empty() && a.bools.is_empty(), "{none}");
|
||||
}
|
||||
assert_eq!(
|
||||
string(" #define SHADOW_QUALITY 2.0f // Shadows [1.0f 2.0f 3.0f]"),
|
||||
Some((
|
||||
"SHADOW_QUALITY".into(),
|
||||
"2.0f".into(),
|
||||
s(&["1.0f", "2.0f", "3.0f"])
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
string("#define SKY_STYLE SKY_REALISTIC // [SKY_REALISTIC SKY_STYLISED]")
|
||||
.unwrap()
|
||||
.1,
|
||||
"SKY_REALISTIC"
|
||||
);
|
||||
assert_eq!(
|
||||
string("const int shadowMapResolution = 2048; // [1024 2048 4096]")
|
||||
.unwrap()
|
||||
.0,
|
||||
"shadowMapResolution"
|
||||
);
|
||||
assert!(string("const int myResolution = 2048; // [1024 2048 4096]").is_none());
|
||||
assert!(string("//#define SHADOW_QUALITY 2 // [1 2 3]").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_defines_and_references() {
|
||||
let a = Annotated::parse(&[
|
||||
"#define BLOOM // Bloom".into(),
|
||||
"//#define DOF".into(),
|
||||
"// #define MOTION_BLUR // Blur".into(),
|
||||
"#define FOG(x) x".into(),
|
||||
"#ifdef BLOOM".into(),
|
||||
"#ifndef DOF // trailing text: not a reference".into(),
|
||||
"#if defined MOTION_BLUR".into(),
|
||||
"const bool shadowHardwareFiltering = true; // Filtering".into(),
|
||||
]);
|
||||
let b: Vec<(&str, bool, Option<&str>)> = a
|
||||
.bools
|
||||
.values()
|
||||
.map(|o| (o.name.as_str(), o.default, o.comment.as_deref()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
b,
|
||||
[
|
||||
("BLOOM", true, Some("Bloom")),
|
||||
("DOF", false, None),
|
||||
("MOTION_BLUR", false, Some("Blur")),
|
||||
("shadowHardwareFiltering", true, Some("Filtering"))
|
||||
]
|
||||
);
|
||||
assert_eq!(a.ifdef_refs.iter().collect::<Vec<_>>(), ["BLOOM"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_written_into_the_lines() {
|
||||
let lines: Vec<String> = [
|
||||
"#define BLOOM",
|
||||
"//#define DOF",
|
||||
"#define QUALITY 2 // [1 2 3]",
|
||||
"const float sunPathRotation = -40.0; // [-40.0 0.0 40.0]",
|
||||
"const bool shadowHardwareFiltering = false;",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
let a = Annotated::parse(&lines);
|
||||
let mut set = OptionSet::default();
|
||||
a.bools.values().for_each(|o| set.add_bool(o));
|
||||
a.strings.values().for_each(|o| set.add_string(o));
|
||||
let saved: BTreeMap<String, String> = [
|
||||
("BLOOM", "false"),
|
||||
("DOF", "true"),
|
||||
("QUALITY", "3"),
|
||||
("sunPathRotation", "0.0"),
|
||||
("shadowHardwareFiltering", "yes"),
|
||||
]
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.into();
|
||||
let values = OptionValues::new(&set, &saved);
|
||||
assert!(
|
||||
!values.bools.contains_key("shadowHardwareFiltering"),
|
||||
"not a boolean: ignored"
|
||||
);
|
||||
let stale = [("QUALITY", "abc"), ("sunPathRotation", "")]
|
||||
.map(|(k, v)| (k.to_owned(), v.to_owned()))
|
||||
.into();
|
||||
assert!(
|
||||
OptionValues::new(&set, &stale).strings.is_empty(),
|
||||
"values the options don't list: ignored"
|
||||
);
|
||||
let edited: Vec<String> = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| a.edit(&values, i, l))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
edited,
|
||||
[
|
||||
"//#define BLOOM",
|
||||
"#define DOF",
|
||||
"#define QUALITY 3 // OptionAnnotatedSource: Changed option",
|
||||
"const float sunPathRotation = 0.0; // [-40.0 0.0 40.0]",
|
||||
"const bool shadowHardwareFiltering = false;",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_defaults_drop_the_option() {
|
||||
let mut set = OptionSet::default();
|
||||
let a = Annotated::parse(&["#define Q 1 // [1 2]".into()]);
|
||||
let b = Annotated::parse(&["#define Q 2 // Quality [1 2]".into()]);
|
||||
let c = Annotated::parse(&["#define Q 1 // Quality [1 2]".into()]);
|
||||
set.add_string(&a.strings[&0]);
|
||||
set.add_string(&c.strings[&0]);
|
||||
assert_eq!(
|
||||
set.strings["Q"].comment.as_deref(),
|
||||
Some("Quality"),
|
||||
"the commented one wins"
|
||||
);
|
||||
set.add_string(&b.strings[&0]);
|
||||
assert!(set.strings.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,594 +0,0 @@
|
||||
//! The C preprocessor pass: Iris's standard macros (`StandardMacros`,
|
||||
//! `IrisDefines`) defined, then every directive and macro expanded by pp-rs,
|
||||
//! the preprocessor naga's GLSL front end uses. The output keeps the inlined
|
||||
//! source's line numbers, so an error later on still names `file:line`.
|
||||
|
||||
use crate::include::Inlined;
|
||||
use pp_rs::{
|
||||
pp::Preprocessor,
|
||||
token::{PreprocessorError, Punct, TokenValue},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The version of Minecraft packs are told they run on (`MC_VERSION`,
|
||||
/// Iris's `formatVersionString("26.3")`).
|
||||
pub const MC_VERSION: &str = "260300";
|
||||
/// The Iris release whose behaviour the translation follows (`IRIS_VERSION`, 1.10.0).
|
||||
pub const IRIS_VERSION: &str = "11000";
|
||||
|
||||
/// Iris's `WorldRenderingPhase`, in order: `MC_RENDER_STAGE_<name>` is the index.
|
||||
pub const RENDER_STAGES: &[&str] = &[
|
||||
"NONE",
|
||||
"SKY",
|
||||
"SUNSET",
|
||||
"CUSTOM_SKY",
|
||||
"SUN",
|
||||
"MOON",
|
||||
"STARS",
|
||||
"VOID",
|
||||
"TERRAIN_SOLID",
|
||||
"TERRAIN_CUTOUT_MIPPED",
|
||||
"TERRAIN_CUTOUT",
|
||||
"ENTITIES",
|
||||
"BLOCK_ENTITIES",
|
||||
"DESTROY",
|
||||
"OUTLINE",
|
||||
"DEBUG",
|
||||
"HAND_SOLID",
|
||||
"TERRAIN_TRANSLUCENT",
|
||||
"TRIPWIRE",
|
||||
"PARTICLES",
|
||||
"CLOUDS",
|
||||
"RAIN_SNOW",
|
||||
"WORLD_BORDER",
|
||||
"HAND_TRANSLUCENT",
|
||||
];
|
||||
|
||||
/// Iris's `BiomeCategories`, in order: `CAT_<name>` is the index.
|
||||
pub const BIOME_CATEGORIES: &[&str] = &[
|
||||
"NONE",
|
||||
"TAIGA",
|
||||
"EXTREME_HILLS",
|
||||
"JUNGLE",
|
||||
"MESA",
|
||||
"PLAINS",
|
||||
"SAVANNA",
|
||||
"ICY",
|
||||
"THE_END",
|
||||
"BEACH",
|
||||
"FOREST",
|
||||
"OCEAN",
|
||||
"DESERT",
|
||||
"RIVER",
|
||||
"SWAMP",
|
||||
"MUSHROOM",
|
||||
"NETHER",
|
||||
"MOUNTAIN",
|
||||
"UNDERGROUND",
|
||||
];
|
||||
|
||||
/// Distant Horizons block kinds Iris always defines (`DH_BLOCK_<name>`).
|
||||
const DH_BLOCKS: &[&str] = &[
|
||||
"UNKNOWN",
|
||||
"LEAVES",
|
||||
"STONE",
|
||||
"WOOD",
|
||||
"METAL",
|
||||
"DIRT",
|
||||
"LAVA",
|
||||
"DEEPSLATE",
|
||||
"SNOW",
|
||||
"SAND",
|
||||
"TERRACOTTA",
|
||||
"NETHER_STONE",
|
||||
"WATER",
|
||||
"GRASS",
|
||||
"AIR",
|
||||
"ILLUMINATED",
|
||||
];
|
||||
|
||||
/// The macros every program is preprocessed with.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Environment {
|
||||
pub defines: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Environment {
|
||||
/// What Iris defines (`IrisDefines.createIrisReplacements`) for a GPU
|
||||
/// whose adapter reports `vendor` and `renderer`, without any GL
|
||||
/// extension (`MC_GL_*`) or mod macro: packs then take their portable
|
||||
/// paths. Biomes (`BIOME_*`) and the texture format come from the game:
|
||||
/// add them with [`Environment::define`].
|
||||
pub fn iris(vendor: &str, renderer: &str, mipmap_levels: u32) -> Self {
|
||||
let mut env = Self {
|
||||
defines: Vec::new(),
|
||||
};
|
||||
env.define("MC_VERSION", MC_VERSION);
|
||||
env.define("MC_MIPMAP_LEVEL", &mipmap_levels.to_string());
|
||||
env.define("IRIS_VERSION", IRIS_VERSION);
|
||||
// Horizon's renderer stands in for GL 4.6 / GLSL 4.60.
|
||||
env.define("MC_GL_VERSION", "460");
|
||||
env.define("MC_GLSL_VERSION", "460");
|
||||
env.define(os_macro(), "");
|
||||
env.define(vendor_macro(vendor), "");
|
||||
env.define(renderer_macro(renderer), "");
|
||||
for flag in [
|
||||
"IS_IRIS",
|
||||
"IRIS_REQUIRES_SEPARATE_ENTITY_DRAWS",
|
||||
"IRIS_HAS_TRANSLUCENCY_SORTING",
|
||||
] {
|
||||
env.define(flag, "");
|
||||
}
|
||||
env.define("MAX_COLOR_BUFFERS", "32");
|
||||
env.define("IRIS_TAG_SUPPORT", "2");
|
||||
for (i, name) in DH_BLOCKS.iter().enumerate() {
|
||||
env.define(&format!("DH_BLOCK_{name}"), &i.to_string());
|
||||
}
|
||||
env.define("MC_NORMAL_MAP", "");
|
||||
env.define("MC_SPECULAR_MAP", "");
|
||||
env.define("MC_RENDER_QUALITY", "1.0");
|
||||
env.define("MC_SHADOW_QUALITY", "1.0");
|
||||
env.define("MC_HAND_DEPTH", "0.125");
|
||||
for (i, name) in RENDER_STAGES.iter().enumerate() {
|
||||
env.define(&format!("MC_RENDER_STAGE_{name}"), &i.to_string());
|
||||
}
|
||||
for (i, name) in BIOME_CATEGORIES.iter().enumerate() {
|
||||
env.define(&format!("CAT_{name}"), &i.to_string());
|
||||
}
|
||||
env.define("PPT_NONE", "0");
|
||||
env.define("PPT_RAIN", "1");
|
||||
env.define("PPT_SNOW", "2");
|
||||
env
|
||||
}
|
||||
|
||||
/// Define `name` (replacing an earlier value).
|
||||
pub fn define(&mut self, name: &str, value: &str) {
|
||||
self.defines.retain(|(n, _)| n != name);
|
||||
self.defines.push((name.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
fn os_macro() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"MC_OS_MAC"
|
||||
} else if cfg!(windows) {
|
||||
"MC_OS_WINDOWS"
|
||||
} else {
|
||||
"MC_OS_LINUX"
|
||||
}
|
||||
}
|
||||
|
||||
/// `StandardMacros.getVendor`.
|
||||
pub fn vendor_macro(vendor: &str) -> &'static str {
|
||||
let v = vendor.to_lowercase();
|
||||
const TABLE: &[(&str, &str)] = &[
|
||||
("ati", "MC_GL_VENDOR_ATI"),
|
||||
("intel", "MC_GL_VENDOR_INTEL"),
|
||||
("nvidia", "MC_GL_VENDOR_NVIDIA"),
|
||||
("amd", "MC_GL_VENDOR_AMD"),
|
||||
("x.org", "MC_GL_VENDOR_XORG"),
|
||||
];
|
||||
TABLE
|
||||
.iter()
|
||||
.find(|(prefix, _)| v.starts_with(prefix))
|
||||
.map_or("MC_GL_VENDOR_OTHER", |(_, m)| m)
|
||||
}
|
||||
|
||||
/// `StandardMacros.getRenderer`.
|
||||
pub fn renderer_macro(renderer: &str) -> &'static str {
|
||||
let r = renderer.to_lowercase();
|
||||
const TABLE: &[(&str, &str)] = &[
|
||||
("amd", "MC_GL_RENDERER_RADEON"),
|
||||
("ati", "MC_GL_RENDERER_RADEON"),
|
||||
("radeon", "MC_GL_RENDERER_RADEON"),
|
||||
("gallium", "MC_GL_RENDERER_GALLIUM"),
|
||||
("intel", "MC_GL_RENDERER_INTEL"),
|
||||
("geforce", "MC_GL_RENDERER_GEFORCE"),
|
||||
("nvidia", "MC_GL_RENDERER_GEFORCE"),
|
||||
("quadro", "MC_GL_RENDERER_QUADRO"),
|
||||
("nvs", "MC_GL_RENDERER_QUADRO"),
|
||||
("mesa", "MC_GL_RENDERER_MESA"),
|
||||
("apple", "MC_GL_RENDERER_APPLE"),
|
||||
];
|
||||
TABLE
|
||||
.iter()
|
||||
.find(|(prefix, _)| r.starts_with(prefix))
|
||||
.map_or("MC_GL_RENDERER_OTHER", |(_, m)| m)
|
||||
}
|
||||
|
||||
/// A program after the preprocessor: plain GLSL with no directives.
|
||||
#[derive(Debug)]
|
||||
pub struct Preprocessed {
|
||||
/// The source, one output line per inlined line (macro expansions joined).
|
||||
pub text: String,
|
||||
/// The `#version` line's words (`["330", "compatibility"]`), if any.
|
||||
pub version: Vec<String>,
|
||||
/// `#extension` lines' words (`["GL_ARB_gpu_shader5", ":", "enable"]`).
|
||||
pub extensions: Vec<Vec<String>>,
|
||||
/// The comments Iris reads directives from (`/* DRAWBUFFERS:01 */`,
|
||||
/// `const int colortex0Format = RGBA16F;`) that are outside `#if`'d out
|
||||
/// blocks, with their output line. Iris keeps comments through its
|
||||
/// preprocessor; pp-rs drops them.
|
||||
pub comments: Vec<(u32, String)>,
|
||||
}
|
||||
|
||||
impl Preprocessed {
|
||||
/// The text with its kept comments put back at the start of their
|
||||
/// lines: what Iris's directive parsers read.
|
||||
pub fn with_comments(&self) -> String {
|
||||
let mut comments = self.comments.iter().peekable();
|
||||
let mut out = String::new();
|
||||
for (i, line) in self.text.lines().enumerate() {
|
||||
while let Some((_, comment)) = comments.next_if(|(at, _)| *at as usize <= i + 1) {
|
||||
out.push_str(comment);
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// What a comment must hold for Iris to read a directive from it.
|
||||
const DIRECTIVE_WORDS: &[&str] = &["DRAWBUFFERS:", "RENDERTARGETS:", "const"];
|
||||
/// The identifier a kept comment is replaced by while pp-rs runs.
|
||||
const COMMENT_MARK: &str = "iris_comment_";
|
||||
|
||||
/// Replace the comments holding [`DIRECTIVE_WORDS`] with an identifier
|
||||
/// (and their line breaks), so the ones in `#if`'d out blocks disappear with
|
||||
/// them. Comments on directive lines are left alone: there the identifier
|
||||
/// would end up in a macro or an `#if`.
|
||||
fn mark_comments(text: &str) -> (String, Vec<String>) {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut comments = Vec::new();
|
||||
let (mut directive, mut line_start, mut continued) = (false, true, false);
|
||||
let mut rest = text;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let comment_len = if rest.starts_with("//") {
|
||||
rest.find('\n').unwrap_or(rest.len())
|
||||
} else if let Some(body) = rest.strip_prefix("/*") {
|
||||
body.find("*/").map_or(rest.len(), |i| i + 4)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if comment_len > 0 {
|
||||
let comment = &rest[..comment_len];
|
||||
if !directive && DIRECTIVE_WORDS.iter().any(|w| comment.contains(w)) {
|
||||
out.push_str(&format!(" {COMMENT_MARK}{} ", comments.len()));
|
||||
out.extend(std::iter::repeat_n('\n', comment.matches('\n').count()));
|
||||
comments.push(comment.to_owned());
|
||||
} else {
|
||||
out.push_str(comment);
|
||||
}
|
||||
if comment.contains('\n') {
|
||||
// A comment spanning lines ends a directive like a line break.
|
||||
directive = false;
|
||||
}
|
||||
line_start = false;
|
||||
rest = &rest[comment_len..];
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\n' => {
|
||||
directive = directive && continued;
|
||||
line_start = !directive;
|
||||
}
|
||||
'#' if line_start => directive = true,
|
||||
c if c.is_whitespace() => {}
|
||||
_ => line_start = false,
|
||||
}
|
||||
if c != '\r' && !c.is_whitespace() || c == '\n' {
|
||||
continued = c == '\\';
|
||||
}
|
||||
out.push(c);
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
(out, comments)
|
||||
}
|
||||
|
||||
/// Whether trimmed `line` is an `#if` or `#elif` directive.
|
||||
pub(crate) fn is_condition(line: &str) -> bool {
|
||||
line.strip_prefix('#')
|
||||
.map(str::trim_start)
|
||||
.and_then(|d| d.strip_prefix("elif").or_else(|| d.strip_prefix("if")))
|
||||
.is_some_and(|r| !r.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
|
||||
}
|
||||
|
||||
/// Whether `word` is a float literal.
|
||||
pub(crate) fn is_float(word: &str) -> bool {
|
||||
word.starts_with(|c: char| c.is_ascii_digit() || c == '.')
|
||||
&& !word.starts_with("0x")
|
||||
&& !word.starts_with("0X")
|
||||
&& word.contains(['.', 'e', 'E'])
|
||||
}
|
||||
|
||||
/// What JCPP's `#if` makes of a number (`Preprocessor.expr` takes
|
||||
/// `NumericValue.longValue`): a float counts as its integer part, so
|
||||
/// `0.75` is 0 and `1.5e1` is 10. `None` for a non-number.
|
||||
pub(crate) fn jcpp_integer(number: &str) -> Option<i64> {
|
||||
let number = number.trim_end_matches(['f', 'F', 'l', 'L', 'u', 'U']);
|
||||
let (mantissa, exponent) = match number.find(['e', 'E']) {
|
||||
Some(i) => (&number[..i], number[i + 1..].parse::<i32>().ok()?),
|
||||
None => (number, 0),
|
||||
};
|
||||
let integer = mantissa.split('.').next()?;
|
||||
let value = if integer.is_empty() {
|
||||
0
|
||||
} else {
|
||||
integer.parse::<i64>().ok()?
|
||||
};
|
||||
Some((value as f64 * 10f64.powi(exponent)) as i64)
|
||||
}
|
||||
|
||||
/// `line` with each word (identifier or number, with an exponent's sign:
|
||||
/// `1e-3`) replaced by what `f` returns for it, if anything. The directive's
|
||||
/// name and the names after `defined` are kept.
|
||||
pub(crate) fn rewrite_condition(line: &str, f: impl Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let (mut rest, mut first, mut after_defined) = (line, true, false);
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if !(c.is_ascii_alphanumeric() || c == '_' || c == '.') {
|
||||
if !c.is_whitespace() && c != '(' && c != '#' {
|
||||
after_defined = false;
|
||||
}
|
||||
out.push(c);
|
||||
rest = &rest[c.len_utf8()..];
|
||||
continue;
|
||||
}
|
||||
let bytes = rest.as_bytes();
|
||||
let mut len = 0;
|
||||
while let Some(&b) = bytes.get(len) {
|
||||
let sign = (b == b'+' || b == b'-')
|
||||
&& bytes[0].is_ascii_digit()
|
||||
&& matches!(bytes[len - 1], b'e' | b'E');
|
||||
if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || sign) {
|
||||
break;
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
let word = &rest[..len];
|
||||
let kept = first || after_defined;
|
||||
match (!kept).then(|| f(word)).flatten() {
|
||||
Some(replacement) => out.push_str(&replacement),
|
||||
None => out.push_str(word),
|
||||
}
|
||||
after_defined = word == "defined";
|
||||
first = false;
|
||||
rest = &rest[len..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Make pp-rs read `text` as JCPP, Iris's preprocessor, does:
|
||||
/// - a `#define` replaces an earlier macro (`Preprocessor.addMacro`; Bloop
|
||||
/// and Steadfast redefine theirs), where pp-rs refuses a different
|
||||
/// redefinition: an `#undef` line goes before every `#define`;
|
||||
/// - in `#if`/`#elif`, a float counts as its integer part, and so does a
|
||||
/// macro always defined as the same float (an option: `#define LEVEL 0.5`);
|
||||
/// a macro always defined empty (Iris's `MC_GL_VENDOR_OTHER`, Bloop's
|
||||
/// `#define TAA_USE_YCOCG`) counts as 0: JCPP reports the missing operand
|
||||
/// and goes on with 0. pp-rs stops on both;
|
||||
/// - `#error "..."` (Photon's guard in programs it disables) lexes: pp-rs
|
||||
/// has no string tokens and fails on the quote, even in a skipped block.
|
||||
///
|
||||
/// Returns the text and the added lines' 1-based numbers in it.
|
||||
fn jcpp_compatible(text: &str, env: &Environment) -> (String, Vec<u32>) {
|
||||
// A `#define` line's name and body.
|
||||
let define = |l: &str| {
|
||||
let rest = l
|
||||
.trim_start()
|
||||
.strip_prefix('#')?
|
||||
.trim_start()
|
||||
.strip_prefix("define")
|
||||
.filter(|rest| rest.starts_with([' ', '\t']))?
|
||||
.trim_start();
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
let (name, body) = rest.split_at(end);
|
||||
let body = body.split("//").next().unwrap_or("").trim();
|
||||
(!name.is_empty()).then(|| (name.to_owned(), body.to_owned()))
|
||||
};
|
||||
// Each macro's body, if every definition has the same one.
|
||||
let mut bodies: HashMap<String, Option<String>> = HashMap::new();
|
||||
let definitions = env
|
||||
.defines
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(text.lines().filter_map(define));
|
||||
for (name, body) in definitions {
|
||||
bodies
|
||||
.entry(name)
|
||||
.and_modify(|b| {
|
||||
if b.as_ref() != Some(&body) {
|
||||
*b = None;
|
||||
}
|
||||
})
|
||||
.or_insert(Some(body));
|
||||
}
|
||||
let number = |word: &str| -> Option<String> {
|
||||
let word = match bodies.get(word) {
|
||||
Some(Some(body)) if body.is_empty() => return Some("0".into()),
|
||||
Some(Some(body)) => body.as_str(),
|
||||
Some(None) => return None,
|
||||
None => word,
|
||||
};
|
||||
is_float(word)
|
||||
.then(|| jcpp_integer(word))
|
||||
.flatten()
|
||||
.map(|n| n.to_string())
|
||||
};
|
||||
let mut out = String::with_capacity(text.len() + text.len() / 16);
|
||||
let mut added = Vec::new();
|
||||
let (mut line, mut continued) = (1, false);
|
||||
for l in text.split_inclusive('\n') {
|
||||
if !continued && let Some((name, _)) = define(l) {
|
||||
out.push_str(&format!("#undef {name}\n"));
|
||||
added.push(line);
|
||||
line += 1;
|
||||
}
|
||||
let directive = l.trim_start().strip_prefix('#').map(str::trim_start);
|
||||
if !continued
|
||||
&& directive.is_some_and(|d| d.starts_with("error") || d.starts_with("warning"))
|
||||
{
|
||||
out.push_str(&l.replace(['"', '\''], ""));
|
||||
} else if !continued && is_condition(l.trim_start()) {
|
||||
out.push_str(&rewrite_condition(l, number));
|
||||
} else {
|
||||
out.push_str(l);
|
||||
}
|
||||
continued = l.trim_end().ends_with('\\');
|
||||
line += 1;
|
||||
}
|
||||
(out, added)
|
||||
}
|
||||
|
||||
/// Expand `source` with `env`'s macros. Errors name the file and line.
|
||||
pub fn preprocess(source: &Inlined, env: &Environment) -> Result<Preprocessed, String> {
|
||||
let (marked, comments) = mark_comments(&source.text);
|
||||
let (text, added) = jcpp_compatible(&marked, env);
|
||||
let text = &text;
|
||||
let line_starts: Vec<usize> = std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
|
||||
.collect();
|
||||
// Lines of the source: the added `#undef` lines don't count.
|
||||
let line_of = |offset: u32| {
|
||||
let line = line_starts.partition_point(|&s| s <= offset as usize) as u32;
|
||||
line - added.partition_point(|&a| a < line) as u32
|
||||
};
|
||||
let mut pp = Preprocessor::new(text);
|
||||
for (name, value) in &env.defines {
|
||||
pp.add_define(name, value)
|
||||
.map_err(|e| format!("macro {name}: {:?}", e.0))?;
|
||||
}
|
||||
let mut out = Preprocessed {
|
||||
text: String::new(),
|
||||
version: Vec::new(),
|
||||
extensions: Vec::new(),
|
||||
comments: Vec::new(),
|
||||
};
|
||||
let mut line = 1;
|
||||
for item in pp {
|
||||
let token = match item {
|
||||
// JCPP lets an `#if` run to the end of the input (Bloop's
|
||||
// taa.fsh never closes its `#ifdef TAA`); pp-rs says so there,
|
||||
// after the last token.
|
||||
Err((PreprocessorError::UnfinishedBlock, _)) => break,
|
||||
item => {
|
||||
item.map_err(|(e, at)| format!("{}: {e:?}", source.locate(line_of(at.start))))?
|
||||
}
|
||||
};
|
||||
let at = line_of(token.location.start);
|
||||
let piece = match token.value {
|
||||
TokenValue::Version(v) => {
|
||||
out.version = v.tokens.into_iter().map(|t| spelling(t.value)).collect();
|
||||
continue;
|
||||
}
|
||||
TokenValue::Extension(e) => {
|
||||
out.extensions
|
||||
.push(e.tokens.into_iter().map(|t| spelling(t.value)).collect());
|
||||
continue;
|
||||
}
|
||||
TokenValue::Pragma(_) => continue,
|
||||
TokenValue::Ident(ref name) if name.starts_with(COMMENT_MARK) => {
|
||||
if let Some(comment) = name[COMMENT_MARK.len()..]
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.and_then(|i| comments.get(i))
|
||||
{
|
||||
out.comments.push((at, comment.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
value => spelling(value),
|
||||
};
|
||||
if at > line {
|
||||
out.text
|
||||
.extend(std::iter::repeat_n('\n', (at - line) as usize));
|
||||
line = at;
|
||||
} else if !out.text.is_empty() {
|
||||
out.text.push(' ');
|
||||
}
|
||||
out.text.push_str(&piece);
|
||||
}
|
||||
out.text.push('\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A token as GLSL source.
|
||||
fn spelling(value: TokenValue) -> String {
|
||||
match value {
|
||||
TokenValue::Ident(s) => s,
|
||||
TokenValue::Integer(i) => {
|
||||
let suffix = if i.signed { "" } else { "u" };
|
||||
// Bit patterns such as 0xFFFFFFFF stay hexadecimal: as decimals
|
||||
// they would overflow a signed int.
|
||||
if i.value > i32::MAX as u64 {
|
||||
format!("{:#X}{suffix}", i.value)
|
||||
} else {
|
||||
format!("{}{suffix}", i.value)
|
||||
}
|
||||
}
|
||||
TokenValue::Float(f) => {
|
||||
let mut s = format!("{:?}", f.value);
|
||||
if !s.contains(['.', 'e', 'i', 'N']) {
|
||||
s.push_str(".0");
|
||||
}
|
||||
s
|
||||
}
|
||||
TokenValue::Punct(p) => punct(p).to_owned(),
|
||||
TokenValue::Version(_) | TokenValue::Extension(_) | TokenValue::Pragma(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn punct(p: Punct) -> &'static str {
|
||||
use Punct::*;
|
||||
match p {
|
||||
AddAssign => "+=",
|
||||
SubAssign => "-=",
|
||||
MulAssign => "*=",
|
||||
DivAssign => "/=",
|
||||
ModAssign => "%=",
|
||||
LeftShiftAssign => "<<=",
|
||||
RightShiftAssign => ">>=",
|
||||
AndAssign => "&=",
|
||||
XorAssign => "^=",
|
||||
OrAssign => "|=",
|
||||
Increment => "++",
|
||||
Decrement => "--",
|
||||
LogicalAnd => "&&",
|
||||
LogicalOr => "||",
|
||||
LogicalXor => "^^",
|
||||
LessEqual => "<=",
|
||||
GreaterEqual => ">=",
|
||||
EqualEqual => "==",
|
||||
NotEqual => "!=",
|
||||
LeftShift => "<<",
|
||||
RightShift => ">>",
|
||||
LeftBrace => "{",
|
||||
RightBrace => "}",
|
||||
LeftParen => "(",
|
||||
RightParen => ")",
|
||||
LeftBracket => "[",
|
||||
RightBracket => "]",
|
||||
LeftAngle => "<",
|
||||
RightAngle => ">",
|
||||
Semicolon => ";",
|
||||
Comma => ",",
|
||||
Colon => ":",
|
||||
Dot => ".",
|
||||
Equal => "=",
|
||||
Bang => "!",
|
||||
Minus => "-",
|
||||
Tilde => "~",
|
||||
Plus => "+",
|
||||
Star => "*",
|
||||
Slash => "/",
|
||||
Percent => "%",
|
||||
Pipe => "|",
|
||||
Caret => "^",
|
||||
Ampersand => "&",
|
||||
Question => "?",
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
//! A Java shader pack's files: a folder or a zip holding a `shaders/` folder,
|
||||
//! found the way Iris finds it (`Iris.loadExternalZipShaderpack`).
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Program source extensions Iris loads (vertex, fragment, geometry,
|
||||
/// compute, tessellation control and evaluation).
|
||||
pub const PROGRAM_EXTENSIONS: &[&str] = &["vsh", "fsh", "gsh", "csh", "tcs", "tes"];
|
||||
|
||||
/// The files under a pack's `shaders/` folder, addressed by `/`-separated
|
||||
/// paths relative to it (`lib/common.glsl`).
|
||||
pub enum PackFiles {
|
||||
Folder(PathBuf),
|
||||
/// A zip is small (a few MB of text), so it is read once.
|
||||
Zip(BTreeMap<String, Vec<u8>>),
|
||||
}
|
||||
|
||||
impl PackFiles {
|
||||
/// Open `path`, a pack folder (with `shaders/` in it) or a `.zip`.
|
||||
pub fn open(path: &Path) -> Result<Self, String> {
|
||||
if path.is_dir() {
|
||||
let shaders = path.join("shaders");
|
||||
return if shaders.is_dir() {
|
||||
Ok(Self::Folder(shaders))
|
||||
} else {
|
||||
Err("no shaders folder".into())
|
||||
};
|
||||
}
|
||||
let file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("not a zip: {e}"))?;
|
||||
let names: Vec<String> = zip.file_names().map(str::to_owned).collect();
|
||||
// `shaders/` at the root, else the first folder ending in `shaders`
|
||||
// (`Pack-main/shaders/`), as Iris walks the zip.
|
||||
let mut prefixes: Vec<&str> = names
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
let at = n.find("shaders/")?;
|
||||
(at == 0 || n.as_bytes()[at - 1] == b'/').then(|| &n[..at + "shaders/".len()])
|
||||
})
|
||||
.collect();
|
||||
prefixes.sort_by_key(|p| (p.len(), *p));
|
||||
let prefix = prefixes.first().ok_or("no shaders folder")?.to_string();
|
||||
let mut files = BTreeMap::new();
|
||||
for name in &names {
|
||||
let Some(rel) = name.strip_prefix(&prefix) else {
|
||||
continue;
|
||||
};
|
||||
if rel.is_empty() || rel.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
let mut entry = zip.by_name(name).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
entry
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
files.insert(rel.to_owned(), bytes);
|
||||
}
|
||||
Ok(Self::Zip(files))
|
||||
}
|
||||
|
||||
/// Raw bytes of a file relative to `shaders/` (leading `/` or `./` stripped),
|
||||
/// or `None` when it doesn't exist.
|
||||
pub fn read_bytes(&self, rel: &str) -> Option<Vec<u8>> {
|
||||
let clean = rel
|
||||
.trim()
|
||||
.trim_start_matches("./")
|
||||
.trim_start_matches('/')
|
||||
.trim_start_matches("shaders/");
|
||||
if clean.is_empty() || clean.contains("..") {
|
||||
return None;
|
||||
}
|
||||
match self {
|
||||
Self::Folder(root) => fs::read(root.join(clean)).ok(),
|
||||
Self::Zip(files) => files.get(clean).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A text file, or `None` when it doesn't exist. Invalid UTF-8 is
|
||||
/// replaced, as packs are often saved in Latin-1.
|
||||
pub fn read(&self, rel: &str) -> Option<String> {
|
||||
let bytes = self.read_bytes(rel)?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
/// The files directly in folder `dir` (`""` for `shaders/` itself), sorted.
|
||||
pub fn list(&self, dir: &str) -> Vec<String> {
|
||||
let prefix = if dir.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{dir}/")
|
||||
};
|
||||
let mut out: Vec<String> = match self {
|
||||
Self::Folder(root) => fs::read_dir(root.join(dir))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
||||
.filter_map(|e| Some(format!("{prefix}{}", e.file_name().to_str()?)))
|
||||
.collect(),
|
||||
Self::Zip(files) => files
|
||||
.keys()
|
||||
.filter(|k| {
|
||||
k.strip_prefix(&prefix)
|
||||
.is_some_and(|rest| !rest.contains('/'))
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
};
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The program folders: `shaders/` itself and the per-dimension folders,
|
||||
/// from `dimension.properties` (`dimension.world-1=minecraft:the_nether`)
|
||||
/// or, without one, Iris's defaults `world0`, `world-1` and `world1`.
|
||||
pub fn program_dirs(&self) -> Vec<String> {
|
||||
let mut dirs = vec![String::new()];
|
||||
match self.read("dimension.properties") {
|
||||
Some(text) => {
|
||||
for line in text.lines() {
|
||||
if let Some((key, _)) = line.trim().split_once('=') {
|
||||
if let Some(dir) = key.trim().strip_prefix("dimension.") {
|
||||
dirs.push(dir.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => dirs.extend(["world0", "world-1", "world1"].map(String::from)),
|
||||
}
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Every program source file of the pack (`composite1.fsh`,
|
||||
/// `world-1/gbuffers_water.vsh`), sorted.
|
||||
pub fn programs(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = self
|
||||
.program_dirs()
|
||||
.iter()
|
||||
.flat_map(|d| self.list(d))
|
||||
.filter(|f| {
|
||||
f.rsplit_once('.')
|
||||
.is_some_and(|(_, ext)| PROGRAM_EXTENSIONS.contains(&ext))
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,838 +0,0 @@
|
||||
//! Custom uniforms: `uniform.<type>.<name> = <expression>` and
|
||||
//! `variable.<type>.<name> = <expression>` in `shaders.properties`, evaluated
|
||||
//! once a frame the way Iris does (`CustomUniforms`, the `kroppeb.stareval`
|
||||
//! parser, `IrisFunctions`, `SmoothFloat`).
|
||||
//!
|
||||
//! Values are lists of floats: one for `bool`, `int` and `float` (a bool is 0
|
||||
//! or 1), two to four for vectors. Iris types each expression; evaluating in
|
||||
//! floats gives the same results for what packs write (its only integer
|
||||
//! operations are `+ - * %`, exact in floats at these sizes; `/` is always a
|
||||
//! float division).
|
||||
//!
|
||||
//! A definition Iris can't resolve (a syntax error, an unknown name or
|
||||
//! function, a cycle) is left out, and so is every definition using it: the
|
||||
//! shader's uniform keeps its default, zero. Here a name is unknown when it is
|
||||
//! neither another definition nor a value the `builtin` lookup gives.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The custom uniforms and variables of a pack, in evaluation order.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CustomUniforms {
|
||||
defs: Vec<Def>,
|
||||
/// Evaluation order (indices into `defs`): dependencies first.
|
||||
order: Vec<usize>,
|
||||
/// What couldn't be parsed or resolved, for the log.
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Def {
|
||||
name: String,
|
||||
/// Components of the result: 1, 2, 3 or 4.
|
||||
size: usize,
|
||||
/// `bool` (0 or 1) or `int` (truncated) rather than float.
|
||||
kind: Kind,
|
||||
/// `uniform.` (given to the shaders) rather than `variable.`.
|
||||
uniform: bool,
|
||||
expr: Expr,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Bool,
|
||||
Int,
|
||||
Float,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Expr {
|
||||
Number(f32),
|
||||
Name(String),
|
||||
/// A vector's component (`.x`, `.g`, `.t`, `.2`...).
|
||||
Access(Box<Expr>, usize),
|
||||
Not(Box<Expr>),
|
||||
Negate(Box<Expr>),
|
||||
Binary(Op, Box<Expr>, Box<Expr>),
|
||||
/// A function; `smooth` calls carry their own state slot.
|
||||
Call(String, Vec<Expr>, usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Op {
|
||||
Multiply,
|
||||
Divide,
|
||||
Remainder,
|
||||
Add,
|
||||
Subtract,
|
||||
Equals,
|
||||
NotEquals,
|
||||
Less,
|
||||
More,
|
||||
LessOrEqual,
|
||||
MoreOrEqual,
|
||||
And,
|
||||
Or,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
/// `IrisOptions`: lower binds tighter; equal priorities associate left.
|
||||
fn priority(self) -> u8 {
|
||||
match self {
|
||||
Op::Multiply | Op::Divide | Op::Remainder => 0,
|
||||
Op::Add | Op::Subtract => 1,
|
||||
Op::Equals
|
||||
| Op::NotEquals
|
||||
| Op::Less
|
||||
| Op::More
|
||||
| Op::LessOrEqual
|
||||
| Op::MoreOrEqual => 2,
|
||||
Op::And | Op::Or => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-frame state: `smooth` accumulators, `random`'s generator and the
|
||||
/// last values.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UniformState {
|
||||
smooth: Vec<SmoothFloat>,
|
||||
seed: u64,
|
||||
values: HashMap<String, Vec<f32>>,
|
||||
}
|
||||
|
||||
impl UniformState {
|
||||
/// A uniform's value this frame; `None` for variables and for
|
||||
/// definitions that couldn't be resolved.
|
||||
pub fn get(&self, name: &str) -> Option<&[f32]> {
|
||||
self.values.get(name).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Every uniform's value this frame, by name.
|
||||
pub fn values(&self) -> &HashMap<String, Vec<f32>> {
|
||||
&self.values
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomUniforms {
|
||||
/// The definitions in `properties` (key, value), in file order.
|
||||
pub fn parse<'a>(properties: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
|
||||
let mut out = Self::default();
|
||||
let mut smooth = 0;
|
||||
for (key, value) in properties {
|
||||
let (uniform, rest) = if let Some(rest) = key.strip_prefix("uniform.") {
|
||||
(true, rest)
|
||||
} else if let Some(rest) = key.strip_prefix("variable.") {
|
||||
(false, rest)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some((ty, name)) = rest.split_once('.') else {
|
||||
continue;
|
||||
};
|
||||
let (kind, size) = match ty {
|
||||
"bool" => (Kind::Bool, 1),
|
||||
"int" => (Kind::Int, 1),
|
||||
"float" => (Kind::Float, 1),
|
||||
"vec2" => (Kind::Float, 2),
|
||||
"vec3" => (Kind::Float, 3),
|
||||
"vec4" => (Kind::Float, 4),
|
||||
_ => {
|
||||
out.problems.push(format!("{key}: unknown type `{ty}`"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if out.defs.iter().any(|d| d.name == name) {
|
||||
out.problems.push(format!("{key}: duplicated variable"));
|
||||
continue;
|
||||
}
|
||||
match parse(value, &mut smooth) {
|
||||
Ok(expr) => out.defs.push(Def {
|
||||
name: name.to_owned(),
|
||||
size,
|
||||
kind,
|
||||
uniform,
|
||||
expr,
|
||||
}),
|
||||
Err(e) => out.problems.push(format!("{key}: {e} ( = {value})")),
|
||||
}
|
||||
}
|
||||
out.order = out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the pack defines none.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.defs.is_empty()
|
||||
}
|
||||
|
||||
/// Dependencies first; a definition in a cycle is left out.
|
||||
fn sort(&mut self) -> Vec<usize> {
|
||||
let index: HashMap<&str, usize> = self
|
||||
.defs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| (d.name.as_str(), i))
|
||||
.collect();
|
||||
// 0 unvisited, 1 in progress, 2 done, 3 broken.
|
||||
let mut mark = vec![0u8; self.defs.len()];
|
||||
let mut order = Vec::new();
|
||||
fn visit(
|
||||
i: usize,
|
||||
defs: &[Def],
|
||||
index: &HashMap<&str, usize>,
|
||||
mark: &mut [u8],
|
||||
order: &mut Vec<usize>,
|
||||
) -> bool {
|
||||
match mark[i] {
|
||||
1 | 3 => return false,
|
||||
2 => return true,
|
||||
_ => {}
|
||||
}
|
||||
mark[i] = 1;
|
||||
let mut names = Vec::new();
|
||||
defs[i].expr.names(&mut names);
|
||||
let ok = names.iter().all(|n| match index.get(n.as_str()) {
|
||||
Some(&j) => visit(j, defs, index, mark, order),
|
||||
None => true,
|
||||
});
|
||||
mark[i] = if ok { 2 } else { 3 };
|
||||
if ok {
|
||||
order.push(i);
|
||||
}
|
||||
ok
|
||||
}
|
||||
for i in 0..self.defs.len() {
|
||||
if !visit(i, &self.defs, &index, &mut mark, &mut order) && mark[i] == 3 {
|
||||
self.problems
|
||||
.push(format!("{}: circular reference", self.defs[i].name));
|
||||
}
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// Evaluate every definition for this frame: `builtin` gives Iris's own
|
||||
/// uniforms, `frame_time` (seconds) drives `smooth`.
|
||||
pub fn update(
|
||||
&self,
|
||||
state: &mut UniformState,
|
||||
frame_time: f32,
|
||||
builtin: &dyn Fn(&str) -> Option<Vec<f32>>,
|
||||
) {
|
||||
let mut values: HashMap<&str, Vec<f32>> = HashMap::new();
|
||||
let mut broken: Vec<&str> = Vec::new();
|
||||
state.values.clear();
|
||||
for &i in &self.order {
|
||||
let def = &self.defs[i];
|
||||
let mut eval = Eval {
|
||||
frame_time,
|
||||
builtin,
|
||||
values: &values,
|
||||
broken: &broken,
|
||||
smooth: &mut state.smooth,
|
||||
seed: &mut state.seed,
|
||||
};
|
||||
let Some(mut value) = eval.expr(&def.expr) else {
|
||||
broken.push(&def.name);
|
||||
continue;
|
||||
};
|
||||
value.resize(def.size, value.last().copied().unwrap_or(0.0));
|
||||
match def.kind {
|
||||
Kind::Bool => value[0] = f32::from(u8::from(value[0] != 0.0)),
|
||||
Kind::Int => value[0] = value[0].trunc(),
|
||||
Kind::Float => {}
|
||||
}
|
||||
if def.uniform {
|
||||
state.values.insert(def.name.clone(), value.clone());
|
||||
}
|
||||
values.insert(&def.name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
fn names(&self, out: &mut Vec<String>) {
|
||||
match self {
|
||||
Expr::Number(_) => {}
|
||||
Expr::Name(n) => out.push(n.clone()),
|
||||
Expr::Access(e, _) | Expr::Not(e) | Expr::Negate(e) => e.names(out),
|
||||
Expr::Binary(_, a, b) => {
|
||||
a.names(out);
|
||||
b.names(out);
|
||||
}
|
||||
Expr::Call(_, args, _) => args.iter().for_each(|a| a.names(out)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Eval<'a> {
|
||||
frame_time: f32,
|
||||
builtin: &'a dyn Fn(&str) -> Option<Vec<f32>>,
|
||||
values: &'a HashMap<&'a str, Vec<f32>>,
|
||||
broken: &'a [&'a str],
|
||||
smooth: &'a mut Vec<SmoothFloat>,
|
||||
seed: &'a mut u64,
|
||||
}
|
||||
|
||||
/// Apply `f` component by component, a scalar standing for every component.
|
||||
fn zip(args: &[Vec<f32>], f: impl Fn(&[f32]) -> f32) -> Vec<f32> {
|
||||
let n = args.iter().map(Vec::len).max().unwrap_or(1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let a: Vec<f32> = args
|
||||
.iter()
|
||||
.map(|v| {
|
||||
if v.len() == 1 {
|
||||
v[0]
|
||||
} else {
|
||||
v.get(i).copied().unwrap_or(0.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
f(&a)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truth(b: bool) -> f32 {
|
||||
f32::from(u8::from(b))
|
||||
}
|
||||
|
||||
impl Eval<'_> {
|
||||
fn expr(&mut self, e: &Expr) -> Option<Vec<f32>> {
|
||||
Some(match e {
|
||||
Expr::Number(n) => vec![*n],
|
||||
Expr::Name(n) => self.name(n)?,
|
||||
Expr::Access(e, i) => vec![*self.expr(e)?.get(*i)?],
|
||||
Expr::Not(e) => zip(&[self.expr(e)?], |a| truth(a[0] == 0.0)),
|
||||
Expr::Negate(e) => self.expr(e)?.iter().map(|v| -v).collect(),
|
||||
Expr::Binary(op, a, b) => {
|
||||
let a = self.expr(a)?;
|
||||
let b = self.expr(b)?;
|
||||
let f = |x: f32, y: f32| match op {
|
||||
Op::Multiply => x * y,
|
||||
Op::Divide => x / y,
|
||||
// Java's `%`, on ints and floats: the sign of x.
|
||||
Op::Remainder => x % y,
|
||||
Op::Add => x + y,
|
||||
Op::Subtract => x - y,
|
||||
Op::Equals => truth(x == y),
|
||||
Op::NotEquals => truth(x != y),
|
||||
Op::Less => truth(x < y),
|
||||
Op::More => truth(x > y),
|
||||
Op::LessOrEqual => truth(x <= y),
|
||||
Op::MoreOrEqual => truth(x >= y),
|
||||
Op::And => truth(x != 0.0 && y != 0.0),
|
||||
Op::Or => truth(x != 0.0 || y != 0.0),
|
||||
};
|
||||
zip(&[a, b], |v| f(v[0], v[1]))
|
||||
}
|
||||
Expr::Call(name, args, slot) => self.call(name, args, *slot)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self, n: &str) -> Option<Vec<f32>> {
|
||||
if self.broken.contains(&n) {
|
||||
return None;
|
||||
}
|
||||
if let Some(v) = self.values.get(n) {
|
||||
return Some(v.clone());
|
||||
}
|
||||
match n {
|
||||
"true" => return Some(vec![1.0]),
|
||||
"false" => return Some(vec![0.0]),
|
||||
"pi" => return Some(vec![std::f32::consts::PI]),
|
||||
_ => {}
|
||||
}
|
||||
(self.builtin)(n)
|
||||
}
|
||||
|
||||
fn random(&mut self) -> f32 {
|
||||
// xorshift64*, seeded once.
|
||||
if *self.seed == 0 {
|
||||
*self.seed = 0x9E37_79B9_7F4A_7C15;
|
||||
}
|
||||
let mut x = *self.seed;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
*self.seed = x;
|
||||
((x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 40) as f32) / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
fn call(&mut self, name: &str, args: &[Expr], slot: usize) -> Option<Vec<f32>> {
|
||||
// `if` evaluates only the branch it takes.
|
||||
if name == "if" {
|
||||
if args.len() < 3 || args.len() % 2 == 0 {
|
||||
return None;
|
||||
}
|
||||
for pair in args[..args.len() - 1].chunks(2) {
|
||||
if self.expr(&pair[0])?[0] != 0.0 {
|
||||
return self.expr(&pair[1]);
|
||||
}
|
||||
}
|
||||
return self.expr(&args[args.len() - 1]);
|
||||
}
|
||||
if name == "smooth" {
|
||||
return self.smooth(args, slot);
|
||||
}
|
||||
let v: Vec<Vec<f32>> = args.iter().map(|a| self.expr(a)).collect::<Option<_>>()?;
|
||||
let unary = |f: fn(f32) -> f32| {
|
||||
(v.len() == 1).then(|| v[0].iter().map(|&x| f(x)).collect::<Vec<f32>>())
|
||||
};
|
||||
let n = v.len();
|
||||
Some(match (name, n) {
|
||||
("sin", 1) => unary(f32::sin)?,
|
||||
("cos", 1) => unary(f32::cos)?,
|
||||
("tan", 1) => unary(f32::tan)?,
|
||||
("asin", 1) => unary(f32::asin)?,
|
||||
("acos", 1) => unary(f32::acos)?,
|
||||
("atan", 1) => unary(f32::atan)?,
|
||||
("atan" | "atan2", 2) => zip(&v, |a| a[0].atan2(a[1])),
|
||||
("torad" | "radians", 1) => unary(f32::to_radians)?,
|
||||
("todeg" | "degrees", 1) => unary(f32::to_degrees)?,
|
||||
("pow", 2) => zip(&v, |a| a[0].powf(a[1])),
|
||||
("exp", 1) => unary(f32::exp)?,
|
||||
("exp2", 1) => unary(f32::exp2)?,
|
||||
("exp10", 1) => unary(|x| 10f32.powf(x))?,
|
||||
("log", 1) => unary(f32::ln)?,
|
||||
("log", 2) => zip(&v, |a| a[1].ln() / a[0].ln()),
|
||||
("log2", 1) => unary(f32::log2)?,
|
||||
("log10", 1) => unary(f32::log10)?,
|
||||
("sqrt", 1) => unary(f32::sqrt)?,
|
||||
("abs", 1) => unary(f32::abs)?,
|
||||
// Math.signum: 0 stays 0.
|
||||
("sign" | "signum", 1) => unary(|x| if x == 0.0 { x } else { x.signum() })?,
|
||||
("floor", 1) => unary(f32::floor)?,
|
||||
("ceil", 1) => unary(f32::ceil)?,
|
||||
("frac", 1) => unary(|x| x - x.floor())?,
|
||||
// Math.round: half up.
|
||||
("round", 1) => unary(|x| (x + 0.5).floor())?,
|
||||
("min", 2..) => zip(&v, |a| a.iter().copied().fold(f32::INFINITY, f32::min)),
|
||||
("max", 2..) => zip(&v, |a| a.iter().copied().fold(f32::NEG_INFINITY, f32::max)),
|
||||
("clamp", 3) => zip(&v, |a| a[0].max(a[1]).min(a[2])),
|
||||
("mix", 3) => zip(&v, |a| a[0] + (a[1] - a[0]) * a[2]),
|
||||
("edge", 2) => zip(&v, |a| truth(a[1] >= a[0])),
|
||||
("fmod", 2) => zip(&v, |a| (a[0] % a[1] + a[1]) % a[1]),
|
||||
("between", 3) => vec![truth(v[0][0] >= v[1][0] && v[0][0] <= v[2][0])],
|
||||
("equals", 3) => vec![truth((v[0][0] - v[1][0]).abs() <= v[2][0])],
|
||||
("in", 2..) => vec![truth(v[1..].iter().any(|x| x[0] == v[0][0]))],
|
||||
("random", 0) => vec![self.random()],
|
||||
("random", 2) => {
|
||||
let r = self.random();
|
||||
vec![v[0][0] + r * (v[1][0] - v[0][0])]
|
||||
}
|
||||
("vec2" | "ivec2" | "bvec2", 2)
|
||||
| ("vec3" | "ivec3" | "bvec3", 3)
|
||||
| ("vec4" | "ivec4" | "bvec4", 4) => {
|
||||
let mut out: Vec<f32> = v.iter().map(|a| a[0]).collect();
|
||||
if name.starts_with('i') {
|
||||
out.iter_mut().for_each(|x| *x = x.trunc());
|
||||
} else if name.starts_with('b') {
|
||||
out.iter_mut().for_each(|x| *x = truth(*x != 0.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `smooth([id,] value[, fadeTime[, fadeDownTime]])`: the half-lives, in
|
||||
/// tenths of a second, default to 1. A constant first argument of two or
|
||||
/// three is OptiFine's id; with four it always is.
|
||||
fn smooth(&mut self, args: &[Expr], slot: usize) -> Option<Vec<f32>> {
|
||||
let id = matches!(args.first(), Some(Expr::Number(_))) && args.len() >= 2;
|
||||
let args = if id || args.len() == 4 {
|
||||
&args[1..]
|
||||
} else {
|
||||
args
|
||||
};
|
||||
let value = self.expr(args.first()?)?[0];
|
||||
let up = match args.get(1) {
|
||||
Some(e) => self.expr(e)?[0],
|
||||
None => 1.0,
|
||||
};
|
||||
let down = match args.get(2) {
|
||||
Some(e) => self.expr(e)?[0],
|
||||
None => up,
|
||||
};
|
||||
if args.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
if self.smooth.len() <= slot {
|
||||
self.smooth.resize(slot + 1, SmoothFloat::default());
|
||||
}
|
||||
Some(vec![self.smooth[slot].update(
|
||||
value,
|
||||
up,
|
||||
down,
|
||||
self.frame_time,
|
||||
)])
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's `SmoothFloat`: exponential smoothing with separate half-lives up
|
||||
/// and down, in tenths of a second; the first value is taken as is.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SmoothFloat {
|
||||
value: Option<f32>,
|
||||
}
|
||||
|
||||
impl SmoothFloat {
|
||||
pub fn update(&mut self, target: f32, half_life_up: f32, half_life_down: f32, dt: f32) -> f32 {
|
||||
let Some(acc) = self.value else {
|
||||
self.value = Some(target);
|
||||
return target;
|
||||
};
|
||||
let half_life = if target > acc {
|
||||
half_life_up
|
||||
} else {
|
||||
half_life_down
|
||||
};
|
||||
let next = if half_life == 0.0 {
|
||||
target
|
||||
} else {
|
||||
let decay = std::f32::consts::LN_2 / (half_life * 0.1);
|
||||
let alpha = 1.0 - (-decay * dt).exp();
|
||||
(1.0 - alpha) * acc + alpha * target
|
||||
};
|
||||
self.value = Some(next);
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one expression; `smooth` counts the `smooth` calls so far (each
|
||||
/// keeps its own state).
|
||||
fn parse(text: &str, smooth: &mut usize) -> Result<Expr, String> {
|
||||
let tokens = tokenize(text)?;
|
||||
let mut p = Parser {
|
||||
tokens,
|
||||
at: 0,
|
||||
smooth,
|
||||
};
|
||||
let e = p.binary(3)?;
|
||||
match p.tokens.get(p.at) {
|
||||
None => Ok(e),
|
||||
Some(t) => Err(format!("unexpected {t:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Token {
|
||||
Number(f32),
|
||||
Id(String),
|
||||
/// `.` followed by an accessor.
|
||||
Access(String),
|
||||
Op(String),
|
||||
Open,
|
||||
Close,
|
||||
Comma,
|
||||
}
|
||||
|
||||
fn tokenize(text: &str) -> Result<Vec<Token>, String> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
let read_while = |i: &mut usize, f: &dyn Fn(char) -> bool| {
|
||||
let start = *i;
|
||||
while *i < chars.len() && f(chars[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
chars[start..*i].iter().collect::<String>()
|
||||
};
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_alphabetic() || c == '_' {
|
||||
out.push(Token::Id(read_while(&mut i, &|c| {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
})));
|
||||
} else if c == '.'
|
||||
&& matches!(
|
||||
out.last(),
|
||||
Some(Token::Id(_) | Token::Close | Token::Access(_))
|
||||
)
|
||||
{
|
||||
i += 1;
|
||||
while i < chars.len() && chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
let access = read_while(&mut i, &|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
if access.is_empty() {
|
||||
return Err("an expression can't end with `.`".into());
|
||||
}
|
||||
out.push(Token::Access(access));
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
let s = read_while(&mut i, &|c| c.is_ascii_alphanumeric() || c == '.');
|
||||
let n = s
|
||||
.parse::<f32>()
|
||||
.or_else(|_| s.trim_end_matches(['f', 'F']).parse::<f32>())
|
||||
.map_err(|_| format!("bad number `{s}`"))?;
|
||||
out.push(Token::Number(n));
|
||||
} else if c == '(' {
|
||||
out.push(Token::Open);
|
||||
i += 1;
|
||||
} else if c == ')' {
|
||||
out.push(Token::Close);
|
||||
i += 1;
|
||||
} else if c == ',' {
|
||||
out.push(Token::Comma);
|
||||
i += 1;
|
||||
} else {
|
||||
let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
|
||||
let op = ["==", "!=", "<=", ">=", "&&", "||"]
|
||||
.into_iter()
|
||||
.find(|o| two == *o)
|
||||
.map(str::to_owned)
|
||||
.or_else(|| "*/%+-<>!≠≤≥".contains(c).then(|| c.to_string()))
|
||||
.ok_or_else(|| format!("unexpected character `{c}`"))?;
|
||||
i += op.chars().count();
|
||||
out.push(Token::Op(op));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
tokens: Vec<Token>,
|
||||
at: usize,
|
||||
smooth: &'a mut usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn next(&mut self) -> Option<Token> {
|
||||
let t = self.tokens.get(self.at).cloned();
|
||||
self.at += 1;
|
||||
t
|
||||
}
|
||||
|
||||
fn peek_op(&self) -> Option<Op> {
|
||||
let Some(Token::Op(o)) = self.tokens.get(self.at) else {
|
||||
return None;
|
||||
};
|
||||
Some(match o.as_str() {
|
||||
"*" => Op::Multiply,
|
||||
"/" => Op::Divide,
|
||||
"%" => Op::Remainder,
|
||||
"+" => Op::Add,
|
||||
"-" => Op::Subtract,
|
||||
"==" => Op::Equals,
|
||||
"!=" | "≠" => Op::NotEquals,
|
||||
"<" => Op::Less,
|
||||
">" => Op::More,
|
||||
"<=" | "≤" => Op::LessOrEqual,
|
||||
">=" | "≥" => Op::MoreOrEqual,
|
||||
"&&" => Op::And,
|
||||
"||" => Op::Or,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Operators of priority `max` or tighter, left to right.
|
||||
fn binary(&mut self, max: u8) -> Result<Expr, String> {
|
||||
let mut left = self.unary()?;
|
||||
while let Some(op) = self.peek_op() {
|
||||
let p = op.priority();
|
||||
if p > max {
|
||||
break;
|
||||
}
|
||||
self.at += 1;
|
||||
// The right side takes only tighter operators: equal priorities
|
||||
// associate to the left.
|
||||
let right = if p == 0 {
|
||||
self.unary()?
|
||||
} else {
|
||||
self.binary(p - 1)?
|
||||
};
|
||||
left = Expr::Binary(op, Box::new(left), Box::new(right));
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// Prefix `!` and `-`, which bind tighter than any binary operator.
|
||||
fn unary(&mut self) -> Result<Expr, String> {
|
||||
match self.tokens.get(self.at) {
|
||||
Some(Token::Op(o)) if o == "!" => {
|
||||
self.at += 1;
|
||||
Ok(Expr::Not(Box::new(self.unary()?)))
|
||||
}
|
||||
Some(Token::Op(o)) if o == "-" => {
|
||||
self.at += 1;
|
||||
Ok(Expr::Negate(Box::new(self.unary()?)))
|
||||
}
|
||||
_ => self.postfix(),
|
||||
}
|
||||
}
|
||||
|
||||
fn postfix(&mut self) -> Result<Expr, String> {
|
||||
let mut e = match self.next() {
|
||||
Some(Token::Number(n)) => Expr::Number(n),
|
||||
Some(Token::Id(id)) if self.tokens.get(self.at) == Some(&Token::Open) => {
|
||||
self.at += 1;
|
||||
let mut args = Vec::new();
|
||||
if self.tokens.get(self.at) == Some(&Token::Close) {
|
||||
self.at += 1;
|
||||
} else {
|
||||
loop {
|
||||
args.push(self.binary(3)?);
|
||||
match self.next() {
|
||||
Some(Token::Comma) => {}
|
||||
Some(Token::Close) => break,
|
||||
t => return Err(format!("expected `,` or `)`, found {t:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
let slot = *self.smooth;
|
||||
if id == "smooth" {
|
||||
*self.smooth += 1;
|
||||
}
|
||||
Expr::Call(id, args, slot)
|
||||
}
|
||||
Some(Token::Id(id)) => Expr::Name(id),
|
||||
Some(Token::Open) => {
|
||||
let e = self.binary(3)?;
|
||||
match self.next() {
|
||||
Some(Token::Close) => e,
|
||||
t => return Err(format!("expected `)`, found {t:?}")),
|
||||
}
|
||||
}
|
||||
t => return Err(format!("expected a value, found {t:?}")),
|
||||
};
|
||||
while let Some(Token::Access(a)) = self.tokens.get(self.at) {
|
||||
let i = match a.as_str() {
|
||||
"x" | "r" | "s" | "0" => 0,
|
||||
"y" | "g" | "t" | "1" => 1,
|
||||
"z" | "b" | "p" | "2" => 2,
|
||||
"w" | "a" | "q" | "3" => 3,
|
||||
_ => return Err(format!("unknown accessor `.{a}`")),
|
||||
};
|
||||
self.at += 1;
|
||||
e = Expr::Access(Box::new(e), i);
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn builtins(name: &str) -> Option<Vec<f32>> {
|
||||
Some(match name {
|
||||
"frameCounter" => vec![7.0],
|
||||
"eyeBrightness" => vec![32.0, 120.0],
|
||||
"isEyeInWater" => vec![0.0],
|
||||
"eyeAltitude" => vec![40.0],
|
||||
"cameraPosition" => vec![1.0, 64.5, -3.0],
|
||||
"rainStrength" => vec![0.0],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn eval(text: &str) -> Option<Vec<f32>> {
|
||||
let u = CustomUniforms::parse([("uniform.float.x", text)]);
|
||||
let mut state = UniformState::default();
|
||||
u.update(&mut state, 0.05, &builtins);
|
||||
state.get("x").map(<[f32]>::to_vec)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_follow_iris_priorities() {
|
||||
assert_eq!(eval("1 + 2 * 3"), Some(vec![7.0]));
|
||||
assert_eq!(eval("10 - 4 - 3"), Some(vec![3.0]));
|
||||
assert_eq!(eval("2 * 3 % 4"), Some(vec![2.0]));
|
||||
assert_eq!(eval("-2 * 3"), Some(vec![-6.0]));
|
||||
assert_eq!(eval("1 < 2 && 3 > 4 || 1"), Some(vec![1.0]));
|
||||
assert_eq!(eval("!(1 == 1)"), Some(vec![0.0]));
|
||||
assert_eq!(eval("frameCounter % 2"), Some(vec![1.0]));
|
||||
assert_eq!(eval("7 / 2"), Some(vec![3.5]));
|
||||
assert_eq!(eval("-7 % 4"), Some(vec![-3.0]));
|
||||
assert_eq!(eval("eyeBrightness.y / 240.0"), Some(vec![0.5]));
|
||||
assert_eq!(eval("cameraPosition.y - 0.5"), Some(vec![64.0]));
|
||||
assert_eq!(eval("2.5f * 2"), Some(vec![5.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions() {
|
||||
assert_eq!(eval("if(eyeBrightness.y > 239.0, 1, 0)"), Some(vec![0.0]));
|
||||
assert_eq!(eval("if(0, 1, 1 == 1, 2, 3)"), Some(vec![2.0]));
|
||||
assert_eq!(
|
||||
eval("clamp(5, 0, 1) + max(1, 4, 2) + min(3, -1)"),
|
||||
Some(vec![4.0])
|
||||
);
|
||||
assert_eq!(eval("fmod(-1, 4)"), Some(vec![3.0]));
|
||||
assert_eq!(eval("in(3, 1, 2, 3)"), Some(vec![1.0]));
|
||||
assert_eq!(
|
||||
eval("between(2, 1, 3) && equals(1.0, 1.05, 0.1)"),
|
||||
Some(vec![1.0])
|
||||
);
|
||||
assert_eq!(eval("frac(2.25) + floor(-0.5) + abs(-2)"), Some(vec![1.25]));
|
||||
assert_eq!(eval("vec2(1, 2).y"), Some(vec![2.0]));
|
||||
// An unknown name or function leaves the uniform out (zero).
|
||||
assert_eq!(eval("biome_precipitation + 1"), None);
|
||||
assert_eq!(eval("nope(1)"), None);
|
||||
assert_eq!(eval("1 +"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_resolve_in_dependency_order() {
|
||||
let u = CustomUniforms::parse([
|
||||
("uniform.float.b", "a * 2"),
|
||||
("variable.float.a", "frameCounter + 1"),
|
||||
("uniform.vec2.v", "vec2(a, b)"),
|
||||
("uniform.bool.flag", "b > 10"),
|
||||
("uniform.float.loop1", "loop2"),
|
||||
("uniform.float.loop2", "loop1"),
|
||||
("uniform.float.bad", "unknown"),
|
||||
("uniform.float.uses_bad", "bad + 1"),
|
||||
]);
|
||||
let mut state = UniformState::default();
|
||||
u.update(&mut state, 0.05, &builtins);
|
||||
assert_eq!(state.get("b"), Some(&[16.0][..]));
|
||||
assert_eq!(state.get("a"), None, "variables aren't uniforms");
|
||||
assert_eq!(state.get("v"), Some(&[8.0, 16.0][..]));
|
||||
assert_eq!(state.get("flag"), Some(&[1.0][..]));
|
||||
assert_eq!(state.get("loop1"), None);
|
||||
assert_eq!(state.get("bad"), None);
|
||||
assert_eq!(state.get("uses_bad"), None);
|
||||
assert!(u.problems.iter().any(|p| p.contains("circular")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_is_iris_exponential_smoothing() {
|
||||
// Complementary's `eyeBrightnessM` and `isEyeInCave`.
|
||||
let u = CustomUniforms::parse([
|
||||
(
|
||||
"uniform.float.m",
|
||||
"smooth(4, eyeBrightness.y / 240.0, 5, 5)",
|
||||
),
|
||||
(
|
||||
"uniform.float.cave",
|
||||
"if(isEyeInWater == 0, 1.0 - smooth(202, if(eyeAltitude < 5.0, eyeBrightness.y / 240.0, 1.0), 6, 12), 0.0)",
|
||||
),
|
||||
("uniform.float.once", "smooth(frameCounter)"),
|
||||
]);
|
||||
assert!(u.problems.is_empty(), "{:?}", u.problems);
|
||||
let mut state = UniformState::default();
|
||||
u.update(&mut state, 0.05, &builtins);
|
||||
// The first value isn't smoothed.
|
||||
assert_eq!(state.get("m"), Some(&[0.5][..]));
|
||||
assert_eq!(state.get("cave"), Some(&[0.0][..]));
|
||||
// Half a second at a half-life of 5 tenths: halfway to the target.
|
||||
let dark = |name: &str| -> Option<Vec<f32>> {
|
||||
match name {
|
||||
"eyeBrightness" => Some(vec![0.0, 0.0]),
|
||||
_ => builtins(name),
|
||||
}
|
||||
};
|
||||
u.update(&mut state, 0.5, &dark);
|
||||
let m = state.get("m").unwrap()[0];
|
||||
assert!((m - 0.25).abs() < 1e-5, "{m}");
|
||||
let mut s = SmoothFloat::default();
|
||||
s.update(0.0, 0.0, 10.0, 0.1);
|
||||
assert_eq!(s.update(1.0, 0.0, 10.0, 0.1), 1.0, "a half-life of 0 jumps");
|
||||
let down = s.update(0.0, 0.0, 10.0, 1.0);
|
||||
assert!((down - 0.5).abs() < 1e-5, "{down}");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#version 120
|
||||
#extension GL_ARB_shader_texture_lod : enable
|
||||
#include "/lib/common.glsl"
|
||||
uniform sampler2D colortex0;
|
||||
varying vec2 texcoord;
|
||||
void main() {
|
||||
#if MC_VERSION >= 260300 && defined IS_IRIS
|
||||
gl_FragData[0] = texture2D(colortex0, texcoord) * float(MC_RENDER_STAGE_TERRAIN_SOLID);
|
||||
#endif
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,7 +0,0 @@
|
||||
#include "settings.glsl"
|
||||
#ifdef BLOOM
|
||||
float bloomStrength = 0.5 * QUALITY;
|
||||
#endif
|
||||
#ifdef DOF
|
||||
float dofStrength = 1.0;
|
||||
#endif
|
||||
@@ -1,7 +0,0 @@
|
||||
// Options, as packs write them.
|
||||
#define BLOOM // Bloom on bright light
|
||||
//#define DOF // Depth of field
|
||||
//#define UNREFERENCED
|
||||
#define QUALITY 2 // Quality [1 2 3]
|
||||
#define NOT_AN_OPTION 3
|
||||
const float sunPathRotation = -40.0; // [-40.0 0.0 40.0]
|
||||
@@ -1 +0,0 @@
|
||||
Not a program.
|
||||
@@ -1,4 +0,0 @@
|
||||
#version 120
|
||||
#include "../lib/common.glsl"
|
||||
#include "/lib/missing.glsl"
|
||||
void main() {}
|
||||
@@ -1,17 +0,0 @@
|
||||
#version 120
|
||||
uniform sampler2D colortex3;
|
||||
varying vec2 texcoord;
|
||||
#ifdef BLOOM
|
||||
/*
|
||||
const int colortex1Format = R8;
|
||||
*/
|
||||
#endif
|
||||
const bool colortex3MipmapEnabled = true; // read with mipmaps
|
||||
void main() {
|
||||
#if 0
|
||||
/* DRAWBUFFERS:12 */
|
||||
#endif
|
||||
/* RENDERTARGETS: 0,1 */
|
||||
gl_FragData[0] = texture2D(colortex3, texcoord);
|
||||
gl_FragData[1] = vec4(1.0);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,7 +0,0 @@
|
||||
#version 120
|
||||
uniform sampler2D colortex0;
|
||||
varying vec2 texcoord;
|
||||
void main() {
|
||||
/* DRAWBUFFERS:0 */
|
||||
gl_FragData[0] = texture2D(colortex0, texcoord);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,5 +0,0 @@
|
||||
#version 120
|
||||
void main() {
|
||||
/* DRAWBUFFERS:7 */
|
||||
gl_FragData[0] = vec4(1.0);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,11 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
/*
|
||||
const int colortex1Format = RGBA16F;
|
||||
const bool colortex2Clear = false;
|
||||
const vec4 colortex3ClearColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
*/
|
||||
void main() {
|
||||
/* DRAWBUFFERS:3 */
|
||||
gl_FragData[0] = vec4(texcoord, 0.0, 1.0);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,6 +0,0 @@
|
||||
#version 120
|
||||
uniform sampler2D colortex0;
|
||||
varying vec2 texcoord;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(colortex0, texcoord);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#version 120
|
||||
varying vec2 texcoord;
|
||||
void main() { gl_Position = ftransform(); texcoord = gl_MultiTexCoord0.xy; }
|
||||
@@ -1,6 +0,0 @@
|
||||
# Full-screen passes: a smaller composite1 that also flips colortex2,
|
||||
# composite2 off, colortex3 at half size.
|
||||
scale.composite1 = 0.5
|
||||
flip.composite1.colortex2 = true
|
||||
program.composite2.enabled = false
|
||||
size.buffer.colortex3 = 0.5 0.5
|
||||
@@ -1,505 +0,0 @@
|
||||
//! The preprocessing half of the Java shader pack translation on a small
|
||||
//! hand-written pack (`tests/fixtures/mini`), and on real packs when
|
||||
//! `HORIZON_TEST_SHADERPACKS` names a folder of them (development only:
|
||||
//! third-party packs are never committed).
|
||||
|
||||
use horizon_shaderpack::{
|
||||
JavaPack, Stage,
|
||||
pipeline::{TargetSettings, TargetSize},
|
||||
preprocess::Environment,
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
fn mini() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini")
|
||||
}
|
||||
|
||||
fn env() -> Environment {
|
||||
Environment::iris("Apple", "Apple M4 Pro", 4)
|
||||
}
|
||||
|
||||
fn saved(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_programs_and_options() {
|
||||
let pack = JavaPack::open(&mini()).unwrap();
|
||||
assert_eq!(
|
||||
pack.programs,
|
||||
["composite.fsh", "composite.vsh", "world-1/composite.fsh"]
|
||||
);
|
||||
assert_eq!(Stage::of(&pack.programs[1]), Some(Stage::Vertex));
|
||||
let bools: Vec<(&str, bool)> = pack
|
||||
.options
|
||||
.bools
|
||||
.values()
|
||||
.map(|o| (o.name.as_str(), o.default))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bools,
|
||||
[("BLOOM", true), ("DOF", false)],
|
||||
"UNREFERENCED has no #ifdef"
|
||||
);
|
||||
let strings: Vec<(&str, &str)> = pack
|
||||
.options
|
||||
.strings
|
||||
.values()
|
||||
.map(|o| (o.name.as_str(), o.default.as_str()))
|
||||
.collect();
|
||||
assert_eq!(strings, [("QUALITY", "2"), ("sunPathRotation", "-40.0")]);
|
||||
assert_eq!(
|
||||
pack.options.strings["QUALITY"].comment.as_deref(),
|
||||
Some("Quality")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocesses_with_defaults_and_with_values() {
|
||||
let pack = JavaPack::open(&mini()).unwrap();
|
||||
let defaults = pack
|
||||
.preprocess("composite.fsh", &pack.values(&BTreeMap::new()), &env())
|
||||
.unwrap();
|
||||
assert_eq!(defaults.version, ["120"]);
|
||||
assert_eq!(
|
||||
defaults.extensions,
|
||||
[["GL_ARB_shader_texture_lod", ":", "enable"]]
|
||||
);
|
||||
let text = defaults
|
||||
.text
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(text.contains("float bloomStrength = 0.5 * 2 ;"), "{text}");
|
||||
assert!(!text.contains("dofStrength"), "{text}");
|
||||
assert!(
|
||||
text.contains("float ( 8 )"),
|
||||
"Iris's render stage macros: {text}"
|
||||
);
|
||||
assert!(!text.contains('#'), "no directive is left: {text}");
|
||||
|
||||
let values = pack.values(&saved(&[
|
||||
("BLOOM", "false"),
|
||||
("DOF", "true"),
|
||||
("QUALITY", "3"),
|
||||
]));
|
||||
let changed = pack
|
||||
.preprocess("composite.fsh", &values, &env())
|
||||
.unwrap()
|
||||
.text;
|
||||
assert!(
|
||||
!changed.contains("bloomStrength") && changed.contains("dofStrength"),
|
||||
"{changed}"
|
||||
);
|
||||
|
||||
let mut source = pack.source("composite.fsh", &values).unwrap();
|
||||
assert!(
|
||||
source
|
||||
.text
|
||||
.contains("#define QUALITY 3 // OptionAnnotatedSource: Changed option")
|
||||
);
|
||||
// Output lines are the inlined lines: errors can name file:line.
|
||||
let line = defaults
|
||||
.text
|
||||
.lines()
|
||||
.position(|l| l.contains("bloomStrength"))
|
||||
.unwrap() as u32
|
||||
+ 1;
|
||||
assert_eq!(source.locate(line), "lib/common.glsl:3");
|
||||
source.text = "#if\n".into();
|
||||
assert!(
|
||||
horizon_shaderpack::preprocess::preprocess(&source, &env())
|
||||
.unwrap_err()
|
||||
.starts_with("composite.fsh:1:")
|
||||
);
|
||||
}
|
||||
|
||||
/// JCPP lets a `#define` replace a macro (Bloop's `TILT_ANGLE`); the added
|
||||
/// `#undef`s don't move the lines errors name.
|
||||
#[test]
|
||||
fn a_define_replaces_an_earlier_one() {
|
||||
use horizon_shaderpack::{include::Inlined, preprocess::preprocess};
|
||||
let text = "#define TILT 5.0\nfloat a = TILT;\n # define TILT 1.5 // radians\nfloat b = TILT;\n#define F(x) x\n#define F(x) (x * 2)\nfloat c = F(1);\n#if\n";
|
||||
let source = Inlined {
|
||||
text: text.into(),
|
||||
origin: (1..=8).map(|l| ("lib/specular.glsl".into(), l)).collect(),
|
||||
};
|
||||
let error = preprocess(&source, &env()).unwrap_err();
|
||||
assert!(error.starts_with("lib/specular.glsl:8:"), "{error}");
|
||||
let source = Inlined {
|
||||
text: text.replace("#if\n", ""),
|
||||
..source
|
||||
};
|
||||
let out = preprocess(&source, &env()).unwrap().text;
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[1], "float a = 5.0 ;");
|
||||
assert_eq!(lines[3], "float b = 1.5 ;");
|
||||
assert_eq!(lines[6], "float c = ( 1 * 2 ) ;");
|
||||
}
|
||||
|
||||
/// Conditions as JCPP reads them: an empty macro counts as 0 (Bloop's
|
||||
/// `#if MC_GL_VENDOR_INTEL || MC_GL_VENDOR_OTHER` with Iris's empty vendor
|
||||
/// macro, its `#define TAA_USE_YCOCG`), a float as its integer part; a
|
||||
/// quoted `#error` lexes, and an `#if` may run to the end.
|
||||
#[test]
|
||||
fn conditions_read_as_jcpp_does() {
|
||||
use horizon_shaderpack::{include::Inlined, preprocess::preprocess};
|
||||
let mut env = env();
|
||||
env.define("MC_GL_VENDOR_OTHER", "");
|
||||
let text = "#define TAA_USE_YCOCG
|
||||
#define LEVEL 0.5
|
||||
#if MC_GL_VENDOR_INTEL || MC_GL_VENDOR_OTHER
|
||||
float gather = 0.0;
|
||||
#else
|
||||
float gather = 1.0;
|
||||
#endif
|
||||
#if TAA_USE_YCOCG
|
||||
float ycocg = 1.0;
|
||||
#endif
|
||||
#if defined TAA_USE_YCOCG && defined(MC_GL_VENDOR_OTHER) && LEVEL == 0
|
||||
float both = 1.0;
|
||||
#endif
|
||||
#ifndef TAA_USE_YCOCG
|
||||
#error \"This program should be disabled\"
|
||||
#endif
|
||||
#ifdef TAA_USE_YCOCG
|
||||
float open = 1.0;
|
||||
";
|
||||
let source = Inlined {
|
||||
text: text.into(),
|
||||
origin: Vec::new(),
|
||||
};
|
||||
let out = preprocess(&source, &env).unwrap().text;
|
||||
let words: Vec<&str> = out.split_whitespace().collect();
|
||||
assert_eq!(
|
||||
words.join(" "),
|
||||
"float gather = 1.0 ; float both = 1.0 ; float open = 1.0 ;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_missing_includes_and_cycles() {
|
||||
let pack = JavaPack::open(&mini()).unwrap();
|
||||
let err = pack
|
||||
.source("world-1/composite.fsh", &Default::default())
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
err,
|
||||
"world-1/composite.fsh:3: #include \"lib/missing.glsl\": file not found"
|
||||
);
|
||||
|
||||
let dir = scratch("cycle");
|
||||
std::fs::create_dir_all(dir.join("shaders/lib")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("shaders/final.fsh"),
|
||||
"#include \"/lib/a.glsl\"\nvoid main() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("shaders/lib/a.glsl"), "#include \"b.glsl\"\n").unwrap();
|
||||
std::fs::write(dir.join("shaders/lib/b.glsl"), "#include \"a.glsl\"\n").unwrap();
|
||||
let pack = JavaPack::open(&dir).unwrap();
|
||||
let err = pack.source("final.fsh", &Default::default()).err().unwrap();
|
||||
assert_eq!(
|
||||
err,
|
||||
"#include cycle: final.fsh -> lib/a.glsl -> lib/b.glsl -> lib/a.glsl"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_zips_with_shaders_in_a_subfolder() {
|
||||
let dir = scratch("zip");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let zip_path = dir.join("Mini-main.zip");
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&zip_path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
let root = mini().join("shaders");
|
||||
for rel in [
|
||||
"composite.fsh",
|
||||
"composite.vsh",
|
||||
"lib/common.glsl",
|
||||
"lib/settings.glsl",
|
||||
"world-1/composite.fsh",
|
||||
] {
|
||||
zip.start_file(format!("Mini-main/shaders/{rel}"), options)
|
||||
.unwrap();
|
||||
zip.write_all(&std::fs::read(root.join(rel)).unwrap())
|
||||
.unwrap();
|
||||
}
|
||||
zip.start_file("Mini-main/docs/shaders/readme.txt", options)
|
||||
.unwrap();
|
||||
zip.finish().unwrap();
|
||||
let zipped = JavaPack::open(&zip_path).unwrap();
|
||||
let folder = JavaPack::open(&mini()).unwrap();
|
||||
assert_eq!(zipped.programs, folder.programs);
|
||||
let values = zipped.values(&BTreeMap::new());
|
||||
assert_eq!(
|
||||
zipped
|
||||
.preprocess("composite.fsh", &values, &env())
|
||||
.unwrap()
|
||||
.text,
|
||||
folder
|
||||
.preprocess("composite.fsh", &values, &env())
|
||||
.unwrap()
|
||||
.text
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_the_full_screen_passes() {
|
||||
let pack = JavaPack::open(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/passes"))
|
||||
.unwrap();
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let properties = pack.properties(&values, &env()).unwrap();
|
||||
let passes = pack.passes("", &values, &env(), &properties).unwrap();
|
||||
|
||||
// Settings from the active comments only: colortex1's R8 is #ifdef'd out.
|
||||
assert_eq!(passes.targets[1].format, "RGBA16F");
|
||||
assert!(!passes.targets[2].clear);
|
||||
assert_eq!(passes.targets[3].clear_color, Some([1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(passes.targets[3].size, Some(TargetSize::Relative(0.5, 0.5)));
|
||||
assert_eq!(passes.targets[4], TargetSettings::default());
|
||||
|
||||
let summary = |p: &horizon_shaderpack::pipeline::Pass| {
|
||||
(
|
||||
p.program.clone(),
|
||||
p.draw_buffers.clone(),
|
||||
p.reads_alt.iter().copied().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
passes.deferred.iter().map(summary).collect::<Vec<_>>(),
|
||||
[("deferred".into(), Some(vec![3]), vec![])]
|
||||
);
|
||||
// composite2 is off; RENDERTARGETS in `#if 0` doesn't count; each pass
|
||||
// reads the side its predecessors wrote, and composite1 flips colortex2.
|
||||
assert_eq!(
|
||||
passes.composite.iter().map(summary).collect::<Vec<_>>(),
|
||||
[
|
||||
("composite".into(), Some(vec![0, 1]), vec![3]),
|
||||
("composite1".into(), Some(vec![0]), vec![0, 1, 3]),
|
||||
]
|
||||
);
|
||||
assert_eq!(passes.composite[0].mipmapped, [3].into());
|
||||
assert_eq!(passes.composite[1].scale, 0.5);
|
||||
assert_eq!(passes.flipped_after_prepare, [].into());
|
||||
// No gbuffers programs: the world goes into colortex0.
|
||||
assert_eq!(passes.world_target, 0);
|
||||
assert_eq!(passes.flipped_after_translucent, [3].into());
|
||||
assert_eq!(passes.flipped_at_end, [1, 2, 3].into());
|
||||
let final_pass = passes.final_pass.unwrap();
|
||||
assert_eq!(
|
||||
(final_pass.program.as_str(), final_pass.draw_buffers),
|
||||
("final", None)
|
||||
);
|
||||
assert_eq!(final_pass.reads_alt, [1, 2, 3].into());
|
||||
}
|
||||
|
||||
/// The world enters the target terrain draws to first (MakeUp's colortex1),
|
||||
/// through Iris's fallbacks when `gbuffers_terrain` is missing or disabled.
|
||||
#[test]
|
||||
fn the_world_goes_where_terrain_draws() {
|
||||
let dir = scratch("world-target");
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
let write = |name: &str, text: &str| std::fs::write(shaders.join(name), text).unwrap();
|
||||
for program in ["composite", "gbuffers_terrain", "gbuffers_textured"] {
|
||||
write(&format!("{program}.vsh"), "void main() {}\n");
|
||||
}
|
||||
write("composite.fsh", "void main() {}\n");
|
||||
write(
|
||||
"gbuffers_textured.fsh",
|
||||
"void main() {\n/* DRAWBUFFERS:12 */\n gl_FragData[0] = vec4(1.0);\n}\n",
|
||||
);
|
||||
write(
|
||||
"gbuffers_terrain.fsh",
|
||||
"void main() {\n#ifdef GRASS\n/* DRAWBUFFERS:42 */\n#else\n/* DRAWBUFFERS:3 */\n#endif\n gl_FragData[0] = vec4(1.0);\n}\n",
|
||||
);
|
||||
let target = |properties: &str| {
|
||||
write("shaders.properties", properties);
|
||||
let pack = JavaPack::open(&dir).unwrap();
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let properties = pack.properties(&values, &env()).unwrap();
|
||||
pack.passes("", &values, &env(), &properties)
|
||||
.unwrap()
|
||||
.world_target
|
||||
};
|
||||
assert_eq!(target(""), 3);
|
||||
assert_eq!(target("program.gbuffers_terrain.enabled = false\n"), 1);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("horizon-shaderpack-{name}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
/// Every program of the packs in `HORIZON_TEST_SHADERPACKS` (folders or
|
||||
/// zips) preprocesses. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_preprocess() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
let mut failures = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let Ok(pack) = JavaPack::open(&entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let mut ok = 0;
|
||||
for program in &pack.programs {
|
||||
match pack.preprocess(program, &values, &env()) {
|
||||
Ok(_) => ok += 1,
|
||||
Err(e) => failures.push(format!("{}: {program}: {e}", entry.path().display())),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}: {ok}/{} programs, {} boolean and {} value options",
|
||||
entry.file_name().to_string_lossy(),
|
||||
pack.programs.len(),
|
||||
pack.options.bools.len(),
|
||||
pack.options.strings.len()
|
||||
);
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// The `shaders.properties` of the packs in `HORIZON_TEST_SHADERPACKS` read
|
||||
/// with their defaults. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_properties() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let Ok(pack) = JavaPack::open(&entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let properties = pack.properties(&values, &env()).unwrap();
|
||||
let profiles = properties.profiles(&pack.options).unwrap();
|
||||
let current = horizon_shaderpack::properties::ShaderProperties::current_profile(
|
||||
&profiles,
|
||||
&pack.options,
|
||||
&values,
|
||||
);
|
||||
let disabled: std::collections::BTreeSet<&str> = pack
|
||||
.programs
|
||||
.iter()
|
||||
.map(|p| horizon_shaderpack::program_name(p))
|
||||
.filter(|p| !properties.program_enabled(p, &pack.options, &values))
|
||||
.collect();
|
||||
let main = properties.screen(None).unwrap();
|
||||
println!(
|
||||
"{}: {} main screen elements, {} sub-screens, {} sliders, profiles {:?} (current {:?}), off: {}",
|
||||
entry.file_name().to_string_lossy(),
|
||||
main.len(),
|
||||
properties.screens().len(),
|
||||
properties.sliders().len(),
|
||||
profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||||
current.map(|p| p.name.as_str()),
|
||||
disabled.into_iter().collect::<Vec<_>>().join(" ")
|
||||
);
|
||||
// Screens name the pack's options (a few stale names are tolerated:
|
||||
// Iris shows them as empty cells).
|
||||
let mut unknown = Vec::new();
|
||||
for screen in std::iter::once(None).chain(properties.screens().into_iter().map(Some)) {
|
||||
for e in properties.screen(screen).unwrap() {
|
||||
if let horizon_shaderpack::properties::MenuElement::Option(o) = e {
|
||||
if !pack.options.bools.contains_key(&o)
|
||||
&& !pack.options.strings.contains_key(&o)
|
||||
{
|
||||
unknown.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" unknown screen options: {unknown:?}");
|
||||
assert!(unknown.len() <= 2, "{unknown:?}");
|
||||
// Every custom uniform parses.
|
||||
let uniforms = properties.custom_uniforms();
|
||||
println!(
|
||||
" custom uniforms: {:?}, sky {:?}",
|
||||
uniforms.problems,
|
||||
properties.sky()
|
||||
);
|
||||
assert!(uniforms.problems.is_empty(), "{:?}", uniforms.problems);
|
||||
}
|
||||
}
|
||||
|
||||
/// The overworld's full-screen passes for the packs in
|
||||
/// `HORIZON_TEST_SHADERPACKS`. Skipped when the variable isn't set.
|
||||
#[test]
|
||||
fn real_packs_passes() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let Ok(pack) = JavaPack::open(&entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let properties = pack.properties(&values, &env()).unwrap();
|
||||
let folder = if pack.programs.iter().any(|p| p.starts_with("world0/")) {
|
||||
"world0"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let passes = pack.passes(folder, &values, &env(), &properties).unwrap();
|
||||
println!("{}:", entry.file_name().to_string_lossy());
|
||||
for (i, t) in passes.targets.iter().enumerate() {
|
||||
if *t != TargetSettings::default() {
|
||||
println!(" colortex{i}: {t:?}");
|
||||
}
|
||||
}
|
||||
for p in passes
|
||||
.begin
|
||||
.iter()
|
||||
.chain(&passes.prepare)
|
||||
.chain(&passes.deferred)
|
||||
.chain(&passes.composite)
|
||||
.chain(&passes.final_pass)
|
||||
{
|
||||
println!(
|
||||
" {} writes {:?} reads alt {:?} mipmaps {:?} scale {}",
|
||||
p.program, p.draw_buffers, p.reads_alt, p.mipmapped, p.scale
|
||||
);
|
||||
}
|
||||
assert!(passes.final_pass.is_some() || !passes.composite.is_empty());
|
||||
// Every planned pass translates, its outputs within its targets.
|
||||
for p in passes
|
||||
.begin
|
||||
.iter()
|
||||
.chain(&passes.prepare)
|
||||
.chain(&passes.deferred)
|
||||
.chain(&passes.composite)
|
||||
.chain(&passes.final_pass)
|
||||
{
|
||||
let program = pack
|
||||
.compile(p, &passes.targets, &values, &env(), &properties)
|
||||
.unwrap();
|
||||
assert!(
|
||||
program
|
||||
.fragment
|
||||
.interface
|
||||
.outputs
|
||||
.keys()
|
||||
.all(|&l| (l as usize) < program.draw_buffers.len())
|
||||
|| p.program.ends_with("final"),
|
||||
"{}: outputs {:?}, targets {:?}",
|
||||
p.program,
|
||||
program.fragment.interface.outputs,
|
||||
program.draw_buffers
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,702 +0,0 @@
|
||||
//! The GLSL → WGSL translation on hand-written compatibility-profile
|
||||
//! programs, checked through naga's IR (locations, bindings, uniform block),
|
||||
//! and on real packs when `HORIZON_TEST_SHADERPACKS` names a folder of them.
|
||||
|
||||
use horizon_shaderpack::{
|
||||
JavaPack, Stage,
|
||||
include::Inlined,
|
||||
preprocess::{Environment, preprocess},
|
||||
program_name,
|
||||
translate::{Geometry, Link, Translated, translate},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn env() -> Environment {
|
||||
Environment::iris("Apple", "Apple M4 Pro", 4)
|
||||
}
|
||||
|
||||
fn run(stage: Stage, source: &str, link: &mut Link) -> Result<Translated, String> {
|
||||
let inlined = Inlined {
|
||||
text: source.to_owned(),
|
||||
origin: Vec::new(),
|
||||
};
|
||||
let pre = preprocess(&inlined, &env())?;
|
||||
translate(stage, &pre.text, link, &|line| format!("line {line}"))
|
||||
}
|
||||
|
||||
/// `(name, location)` of an entry point's inputs (`result: false`) or outputs.
|
||||
fn io(t: &Translated, result: bool) -> BTreeMap<String, u32> {
|
||||
let f = &t.module.entry_points[0].function;
|
||||
let mut out = BTreeMap::new();
|
||||
let mut add =
|
||||
|name: Option<&String>, binding: &Option<naga::Binding>, ty: naga::Handle<naga::Type>| {
|
||||
match (binding, &t.module.types[ty].inner) {
|
||||
(Some(naga::Binding::Location { location, .. }), _) => {
|
||||
out.insert(name.cloned().unwrap_or_default(), *location);
|
||||
}
|
||||
(None, naga::TypeInner::Struct { members, .. }) => {
|
||||
for m in members {
|
||||
if let Some(naga::Binding::Location { location, .. }) = &m.binding {
|
||||
out.insert(m.name.clone().unwrap_or_default(), *location);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
if result {
|
||||
if let Some(r) = &f.result {
|
||||
add(None, &r.binding, r.ty);
|
||||
}
|
||||
} else {
|
||||
for a in &f.arguments {
|
||||
add(a.name.as_ref(), &a.binding, a.ty);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Group 0 bindings of the module's resources, by variable name.
|
||||
fn bindings(t: &Translated) -> BTreeMap<String, u32> {
|
||||
t.module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, v)| Some((v.name.clone()?, v.binding.as_ref()?.binding)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The uniform block's member names, in order.
|
||||
fn block_members(t: &Translated) -> Vec<String> {
|
||||
t.module
|
||||
.global_variables
|
||||
.iter()
|
||||
.find(|(_, v)| v.space == naga::AddressSpace::Uniform)
|
||||
.map(|(_, v)| match &t.module.types[v.ty].inner {
|
||||
naga::TypeInner::Struct { members, .. } => {
|
||||
members.iter().filter_map(|m| m.name.clone()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const VSH: &str = r#"
|
||||
#version 120
|
||||
attribute vec4 mc_Entity;
|
||||
uniform mat4 gbufferModelView;
|
||||
uniform bool hasSkylight;
|
||||
varying vec2 texcoord;
|
||||
varying vec4 color;
|
||||
varying mat3 tbn;
|
||||
varying float entity;
|
||||
void main() {
|
||||
gl_Position = ftransform();
|
||||
texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
|
||||
color = gl_Color;
|
||||
gl_FrontColor = gl_Color;
|
||||
tbn = mat3(gbufferModelView) * gl_NormalMatrix;
|
||||
entity = hasSkylight ? mc_Entity.x : 0.0;
|
||||
}
|
||||
"#;
|
||||
|
||||
const FSH: &str = r#"
|
||||
#version 120
|
||||
uniform sampler2D texture;
|
||||
uniform sampler2DShadow shadowtex0;
|
||||
uniform float frameTimeCounter;
|
||||
varying vec2 texcoord;
|
||||
varying vec4 color;
|
||||
varying mat3 tbn;
|
||||
float phase = frameTimeCounter * 2.0;
|
||||
const float weights[] = float[](0.25, 0.5, 0.25);
|
||||
vec4 sampleTwice(sampler2D tex, vec2 uv) {
|
||||
return texture2D(tex, uv) + texture2D(tex, uv + vec2(phase));
|
||||
}
|
||||
float shade(vec3 p) {
|
||||
float texture = shadow2D(shadowtex0, p).x;
|
||||
return texture * weights[1];
|
||||
}
|
||||
void main() {
|
||||
vec4 albedo = sampleTwice(texture, texcoord) * color;
|
||||
if (isnan(albedo.r)) {
|
||||
discard;
|
||||
albedo = vec4(0.0);
|
||||
}
|
||||
gl_FragData[0] = albedo * shade(tbn[2]) * gl_Color;
|
||||
gl_FragData[1] = vec4(texture(shadowtex0, vec3(texcoord, 0.5)).x);
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn translates_a_compatibility_profile_pass() {
|
||||
let mut link = Link::default();
|
||||
let vs = run(Stage::Vertex, VSH, &mut link).unwrap();
|
||||
let fs = run(Stage::Fragment, FSH, &mut link).unwrap();
|
||||
|
||||
// The varyings meet at the same locations, the mat3 as three columns.
|
||||
let outs = io(&vs, true);
|
||||
let ins = io(&fs, false);
|
||||
for name in [
|
||||
"texcoord",
|
||||
"color",
|
||||
"tbn_iris0",
|
||||
"tbn_iris1",
|
||||
"tbn_iris2",
|
||||
"iris_FrontColor",
|
||||
] {
|
||||
assert_eq!(
|
||||
outs.get(name),
|
||||
ins.get(name),
|
||||
"{name}: vs {outs:?} fs {ins:?}"
|
||||
);
|
||||
assert!(outs.contains_key(name), "{name} in {outs:?}");
|
||||
}
|
||||
assert!(outs.contains_key("entity") && !ins.contains_key("entity"));
|
||||
// Attributes: the pack's, then the compatibility ones it reads.
|
||||
let attributes: Vec<String> = io(&vs, false).into_keys().collect();
|
||||
assert_eq!(
|
||||
attributes,
|
||||
[
|
||||
"iris_Color",
|
||||
"iris_MultiTexCoord0",
|
||||
"iris_Vertex",
|
||||
"mc_Entity"
|
||||
]
|
||||
);
|
||||
// gl_FragData[n] are the outputs at location n.
|
||||
assert_eq!(
|
||||
fs.interface.outputs.keys().copied().collect::<Vec<_>>(),
|
||||
[0, 1]
|
||||
);
|
||||
assert_eq!(
|
||||
io(&fs, true).values().copied().collect::<BTreeSet<_>>(),
|
||||
[0, 1].into()
|
||||
);
|
||||
|
||||
// One block whose members extend across the pass: the fragment program's
|
||||
// starts with the vertex program's, so offsets agree.
|
||||
let vs_block = block_members(&vs);
|
||||
let fs_block = block_members(&fs);
|
||||
assert_eq!(
|
||||
vs_block,
|
||||
[
|
||||
"gbufferModelView",
|
||||
"iris_bool_hasSkylight",
|
||||
"iris_ModelViewProjectionMatrix",
|
||||
"iris_TextureMatrix",
|
||||
"iris_NormalMatrix"
|
||||
]
|
||||
);
|
||||
assert_eq!(fs_block[..vs_block.len()], vs_block[..]);
|
||||
assert_eq!(fs_block[vs_block.len()..], ["frameTimeCounter"]);
|
||||
|
||||
// Samplers split into texture + sampler bindings, shared by the pass.
|
||||
let b = bindings(&fs);
|
||||
let block = fs
|
||||
.module
|
||||
.global_variables
|
||||
.iter()
|
||||
.find(|(_, v)| v.space == naga::AddressSpace::Uniform)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(
|
||||
block.binding.as_ref().map(|b| (b.group, b.binding)),
|
||||
Some((0, 0))
|
||||
);
|
||||
for name in ["texture_t", "texture_s", "shadowtex0_t", "shadowtex0_s"] {
|
||||
assert_eq!(b[name], link.bindings[name], "{name}");
|
||||
}
|
||||
assert_eq!(
|
||||
fs.interface.samplers,
|
||||
[
|
||||
("texture".into(), "sampler2D".into()),
|
||||
("shadowtex0".into(), "sampler2DShadow".into())
|
||||
]
|
||||
);
|
||||
// A local named `texture` hides the sampler; the sampler parameter is split.
|
||||
assert!(
|
||||
fs.glsl.contains(
|
||||
"float texture = vec4(texture(sampler2DShadow(shadowtex0_t, shadowtex0_s), p)).x;"
|
||||
),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(
|
||||
fs.glsl
|
||||
.contains("vec4 sampleTwice(texture2D tex_t, sampler tex_s, vec2 uv)"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(
|
||||
fs.glsl
|
||||
.contains("sampleTwice(texture_t, texture_s, texcoord)"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
// Global initialisers run first; the unsized array is sized.
|
||||
assert!(
|
||||
fs.glsl.contains("phase = frameTimeCounter * 2.;"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(
|
||||
fs.glsl
|
||||
.contains("const float weights[3] = float[3](0.25, 0.5, 0.25);"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
// The code after `discard` is gone, isnan is a helper.
|
||||
assert!(!fs.glsl.contains("albedo = vec4(0.);"), "{}", fs.glsl);
|
||||
assert!(fs.glsl.contains("iris_isnan(albedo.r)"), "{}", fs.glsl);
|
||||
assert!(fs.wgsl.contains("@fragment"));
|
||||
// GL's clip space becomes wgpu's after the program's own `main`.
|
||||
assert!(
|
||||
vs.glsl
|
||||
.contains("gl_Position.y = -gl_Position.y;\n gl_Position.z = (gl_Position.z + gl_Position.w) * 0.5;\n}"),
|
||||
"{}",
|
||||
vs.glsl
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_errors_with_their_place() {
|
||||
let mut link = Link::default();
|
||||
let err = run(
|
||||
Stage::Fragment,
|
||||
"void main() {\n float x = ;\n}\n",
|
||||
&mut link,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(err.starts_with("line 2:"), "{err}");
|
||||
let err = run(Stage::Geometry, "void main() {}", &mut link)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err, "Geometry programs are not supported");
|
||||
let mut link = Link::default();
|
||||
run(
|
||||
Stage::Vertex,
|
||||
"uniform float a;\nvoid main() { gl_Position = vec4(a); }",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
let err = run(
|
||||
Stage::Fragment,
|
||||
"uniform int a;\nvoid main() { gl_FragColor = vec4(a); }",
|
||||
&mut link,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err, "uniform a is declared as both `float a` and `int a`");
|
||||
}
|
||||
|
||||
/// What naga refuses and drivers accept: `vec % scalar` (Noble), `sqrt` of
|
||||
/// a negative constant (Bloop's NaN), constants naga can't fold (Bliss's
|
||||
/// `?:`, `mix`), 16-bit types (Noble on NVIDIA), `mat2(vec4)` (Bloop),
|
||||
/// `1 << 31` (Rethinking Voxels), vertex `texture2D` without LOD (Bliss),
|
||||
/// nested array varyings (Noble), and struct varyings (Photon).
|
||||
#[test]
|
||||
fn legalises_what_naga_refuses() {
|
||||
let mut link = Link::default();
|
||||
let vs = run(
|
||||
Stage::Vertex,
|
||||
"uniform sampler2D colortex0;
|
||||
struct CloudsParameters {
|
||||
vec3 color;
|
||||
float density;
|
||||
};
|
||||
varying CloudsParameters clouds;
|
||||
varying vec3 grid[2][2];
|
||||
void main() {
|
||||
vec4 s = texture2D(colortex0, vec2(0.5));
|
||||
clouds.color = s.rgb;
|
||||
clouds.density = s.a;
|
||||
grid[0][0] = s.rgb;
|
||||
grid[0][1] = s.gbr;
|
||||
grid[1][0] = s.brg;
|
||||
grid[1][1] = vec3(1.0);
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
reparse(&vs.wgsl).unwrap();
|
||||
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
"uniform vec2 uv;
|
||||
struct CloudsParameters {
|
||||
vec3 color;
|
||||
float density;
|
||||
};
|
||||
varying CloudsParameters clouds;
|
||||
varying vec3 grid[2][2];
|
||||
const int RES = 256 % 100;
|
||||
const int SIGN_BIT = 1 << 31;
|
||||
const vec3 TARGET = length(vec3(1.0)) < 0.001 ? vec3(0.0) : normalize(vec3(1.0));
|
||||
const vec3 TINT = mix(TARGET, vec3(1.0), 0.5);
|
||||
const vec3 HALF = TINT * 0.5;
|
||||
u16vec2 pack(uint v) { return u16vec2(v & 65535u, v >> 16u); }
|
||||
float nan() { return sqrt(-1.0); }
|
||||
void main() {
|
||||
ivec2 texel = ivec2(uv) % RES;
|
||||
texel %= 7;
|
||||
uint packed = packFloat2x16(f16vec2(HALF.xy));
|
||||
mat2 rot = mat2(vec4(1.0, 0.0, 0.0, 1.0));
|
||||
vec2 r = rot * vec2(clouds.density, float(SIGN_BIT != 0));
|
||||
gl_FragColor = vec4(vec2(texel) + r + clouds.color.xy + grid[1][0].xy, float(pack(packed).x), nan());
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"const int RES = 256 % 100;",
|
||||
"vec3 TARGET;",
|
||||
"vec3 TINT;",
|
||||
"vec3 HALF;",
|
||||
"ivec2 texel = iris_mod(ivec2(uv), RES);",
|
||||
"texel = iris_mod(texel, 7);",
|
||||
"uvec2 pack(uint v)",
|
||||
"return iris_sqrt(-1.);",
|
||||
"uint packed = packHalf2x16(vec2(HALF.xy));",
|
||||
"iris_mat2(",
|
||||
] {
|
||||
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
|
||||
}
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
}
|
||||
|
||||
/// What Photon, Chocapic13', MakeUp Ultra Fast and Bliss write and GL takes:
|
||||
/// lookups without a level in a vertex shader, a struct passed between
|
||||
/// stages, `textureGather`, a `bool` given to `vec3(..)`, `const in`
|
||||
/// parameters and an array field in a struct built by its constructor.
|
||||
#[test]
|
||||
fn legalises_what_photon_and_chocapic_write() {
|
||||
let mut link = Link::default();
|
||||
let header = "uniform sampler2D colortex0;
|
||||
uniform sampler2DShadow shadowtex0;
|
||||
struct Clouds { float coverage; vec2 wind; mat3 turn; int layers; };
|
||||
";
|
||||
let vs = run(
|
||||
Stage::Vertex,
|
||||
&format!(
|
||||
"{header}flat varying Clouds clouds;
|
||||
void main() {{
|
||||
clouds.coverage = texture2D(colortex0, vec2(0.5)).r + shadow2D(shadowtex0, vec3(0.5)).r;
|
||||
clouds.wind = textureOffset(colortex0, vec2(0.5), ivec2(1, 0), 2.0).rg;
|
||||
clouds.turn = mat3(1.0);
|
||||
clouds.layers = 2;
|
||||
gl_Position = ftransform();
|
||||
}}
|
||||
"
|
||||
),
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"textureLod(sampler2D(colortex0_t",
|
||||
"vec2(0.5), 0.)",
|
||||
"textureLodOffset(sampler2D(colortex0_t",
|
||||
"clouds_iriscoverage = clouds.coverage;",
|
||||
"clouds_iristurn_iris1 = clouds.turn[1];",
|
||||
] {
|
||||
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
|
||||
}
|
||||
reparse(&vs.wgsl).unwrap();
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
&format!(
|
||||
"{header}flat varying Clouds clouds;
|
||||
struct Spline {{ float slope; float[3] coeffs; }};
|
||||
float edge(const in float x, const float r) {{ return float(x < r); }}
|
||||
void main() {{
|
||||
const Spline spline = Spline(0.0, float[3](1.0, 2.0, 3.0));
|
||||
vec4 four = textureGather(colortex0, vec2(0.5), 1);
|
||||
vec3 disc = vec3(clouds.coverage < 0.5);
|
||||
vec2 cell = vec2(0.0);
|
||||
float part = modf(clouds.coverage * 4.0, cell.y);
|
||||
gl_FragColor = vec4(disc * edge(four.x, spline.coeffs[1]), clouds.wind.x + float(clouds.layers) + part + cell.y);
|
||||
}}
|
||||
"
|
||||
),
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"iris_gather(colortex0_t",
|
||||
"clouds.coverage = clouds_iriscoverage;",
|
||||
"float edge(in float x, float r)",
|
||||
"cell.y = trunc(",
|
||||
] {
|
||||
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
|
||||
}
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
// The vertex program's outputs are the fragment program's inputs.
|
||||
assert_eq!(
|
||||
io(&vs, true).get("clouds_iriswind"),
|
||||
io(&fs, false).get("clouds_iriswind")
|
||||
);
|
||||
}
|
||||
|
||||
/// A `gbuffers_*` program over Horizon's geometry: its attributes are
|
||||
/// globals the glue fills before `main`, its bindings in group 2, and its
|
||||
/// fragment discards below Iris's alpha test. A `case` ending in `return`
|
||||
/// (Photon's displacement) isn't a fall-through for naga.
|
||||
#[test]
|
||||
fn geometry_programs_take_their_attributes_from_the_glue() {
|
||||
let glue = Geometry {
|
||||
vertex_glue: "layout(location = 0) in vec3 hz_position;
|
||||
layout(std430, set = 3, binding = 0) readonly buffer HzTable { int hz_table[]; };
|
||||
vec4 hz_pos;
|
||||
void hz_vertex() { hz_pos = vec4(hz_position, 1.0) + float(hz_table[0]); }"
|
||||
.into(),
|
||||
attributes: [
|
||||
("iris_Vertex".to_owned(), "hz_pos".to_owned()),
|
||||
(
|
||||
"mc_Entity".to_owned(),
|
||||
"vec4(8.0, -1.0, 0.0, 0.0)".to_owned(),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
alpha_test: Some(0.1),
|
||||
};
|
||||
let mut link = Link {
|
||||
set: 2,
|
||||
geometry: Some(glue),
|
||||
..Link::default()
|
||||
};
|
||||
let vs = run(
|
||||
Stage::Vertex,
|
||||
"uniform sampler2D noisetex;
|
||||
attribute vec4 mc_Entity;
|
||||
attribute vec2 mc_midTexCoord;
|
||||
varying vec2 uv;
|
||||
vec3 sway(vec3 p, int id) {
|
||||
switch (id) {
|
||||
case 8:
|
||||
return p + 1.0;
|
||||
case 9:
|
||||
case 10:
|
||||
return p - 1.0;
|
||||
default:
|
||||
return p;
|
||||
}
|
||||
}
|
||||
void main() {
|
||||
uv = gl_MultiTexCoord0.xy + mc_midTexCoord;
|
||||
gl_Position = gl_ModelViewProjectionMatrix * vec4(sway(gl_Vertex.xyz, int(mc_Entity.x)), 1.0);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"hz_vertex();",
|
||||
"mc_Entity = vec4(vec4(8.0, -1.0, 0.0, 0.0));",
|
||||
"iris_Vertex = vec4(hz_pos);",
|
||||
"mc_midTexCoord = vec2(0);",
|
||||
"set = 2, binding = 0) uniform IrisUniforms",
|
||||
] {
|
||||
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
|
||||
}
|
||||
// Horizon's vertex input is the only one.
|
||||
assert_eq!(
|
||||
io(&vs, false).into_keys().collect::<Vec<_>>(),
|
||||
["hz_position"]
|
||||
);
|
||||
reparse(&vs.wgsl).unwrap();
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
"uniform sampler2D gtexture;
|
||||
varying vec2 uv;
|
||||
void main() {
|
||||
/* DRAWBUFFERS:02 */
|
||||
gl_FragData[0] = texture2D(gtexture, uv);
|
||||
gl_FragData[1] = vec4(1.0);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
fs.glsl.contains("if (!(iris_FragData0.a > 0.1)) discard;"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(fs.glsl.contains("set = 2, binding = "), "{}", fs.glsl);
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
}
|
||||
|
||||
/// A fragment output narrower than its four-channel target (Steadfast's
|
||||
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
|
||||
/// GL fills the missing channels, wgpu refuses the pipeline.
|
||||
#[test]
|
||||
fn narrow_fragment_outputs_fill_four_channels() {
|
||||
let mut link = Link::default();
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
"uniform vec3 tint;
|
||||
layout(location = 0) out vec3 color;
|
||||
layout(location = 1) out float depth;
|
||||
out vec4 extra;
|
||||
void main() {
|
||||
color = tint;
|
||||
depth = 0.5;
|
||||
extra = vec4(tint, 0.25);
|
||||
}
|
||||
",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
for wanted in [
|
||||
"layout(location = 0) out vec4 iris_out_color;",
|
||||
"iris_out_color = vec4(color, 1.0);",
|
||||
"layout(location = 1) out vec4 iris_out_depth;",
|
||||
"iris_out_depth = vec4(depth, 0.0, 0.0, 1.0);",
|
||||
"layout(location = 2) out vec4 extra;",
|
||||
] {
|
||||
assert!(fs.glsl.contains(wanted), "{wanted}\n{}", fs.glsl);
|
||||
}
|
||||
assert_eq!(
|
||||
fs.interface.outputs.values().collect::<Vec<_>>(),
|
||||
["color", "depth", "extra"]
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&fs.wgsl).unwrap();
|
||||
let entry = &module.entry_points[0];
|
||||
let result = entry.function.result.as_ref().unwrap();
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
panic!("{}", fs.wgsl);
|
||||
};
|
||||
for m in members {
|
||||
assert!(
|
||||
matches!(
|
||||
module.types[m.ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: naga::VectorSize::Quad,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{}",
|
||||
fs.wgsl
|
||||
);
|
||||
}
|
||||
reparse(&fs.wgsl).unwrap();
|
||||
}
|
||||
|
||||
/// The written WGSL, read back and validated: what wgpu is given.
|
||||
fn reparse(wgsl: &str) -> Result<(), String> {
|
||||
let module = naga::front::wgsl::parse_str(wgsl).map_err(|e| e.emit_to_string(wgsl))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.emit_to_string(wgsl))
|
||||
}
|
||||
|
||||
/// Every program the packs in `HORIZON_TEST_SHADERPACKS` enable by default
|
||||
/// (`program.*.enabled` in their shaders.properties)
|
||||
/// translates to WGSL that reads back (each pass's `.vsh` before its `.fsh`). Skipped when the
|
||||
/// variable isn't set. Distant Horizons, Colorwheel and Voxy programs are
|
||||
/// left out: Iris loads them only with those mods.
|
||||
#[test]
|
||||
fn real_packs_translate() {
|
||||
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
|
||||
return;
|
||||
};
|
||||
let mut failures = Vec::new();
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let Ok(pack) = JavaPack::open(&entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let values = pack.values(&BTreeMap::new());
|
||||
let properties = pack.properties(&values, &env()).unwrap();
|
||||
let mut passes: BTreeMap<&str, Vec<&String>> = BTreeMap::new();
|
||||
for p in &pack.programs {
|
||||
let file = p.rsplit('/').next().unwrap();
|
||||
if ["dh_", "clrwl_", "voxy"]
|
||||
.iter()
|
||||
.any(|m| file.starts_with(m))
|
||||
|| !properties.program_enabled(program_name(p), &pack.options, &values)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
passes
|
||||
.entry(p.rsplit_once('.').unwrap().0)
|
||||
.or_default()
|
||||
.push(p);
|
||||
}
|
||||
let (mut ok, mut total) = (0, 0);
|
||||
for programs in passes.values_mut() {
|
||||
programs.sort_by_key(|p| !p.ends_with(".vsh"));
|
||||
let mut link = Link {
|
||||
image_formats: properties.image_formats(),
|
||||
..Link::default()
|
||||
};
|
||||
for p in programs.iter() {
|
||||
total += 1;
|
||||
match pack
|
||||
.translate(p, &values, &env(), &mut link)
|
||||
.and_then(|t| reparse(&t.wgsl))
|
||||
{
|
||||
Ok(()) => ok += 1,
|
||||
Err(e) => {
|
||||
failures.push(format!("{}: {e}", entry.file_name().to_string_lossy()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}: {ok}/{total} programs translated",
|
||||
entry.file_name().to_string_lossy()
|
||||
);
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textures_share_samplers_by_name() {
|
||||
let mut link = Link {
|
||||
shared_samplers: [("colortex0", "iris_linear"), ("colortex1", "iris_linear")]
|
||||
.map(|(a, b)| (a.to_owned(), b.to_owned()))
|
||||
.into(),
|
||||
..Link::default()
|
||||
};
|
||||
let fs = run(
|
||||
Stage::Fragment,
|
||||
"uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nuniform sampler2D custom;\n\
|
||||
uniform sampler2DShadow shadowtex0;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = texture2D(colortex0, uv) + texture2D(colortex1, uv)\n\
|
||||
+ texture2D(custom, uv) + shadow2D(shadowtex0, vec3(uv, 0.5)); }",
|
||||
&mut link,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs.glsl.matches("uniform sampler iris_linear;").count(),
|
||||
1,
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(
|
||||
fs.glsl.contains("sampler2D(colortex1_t, iris_linear)"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert!(fs.glsl.contains("uniform sampler custom_s;"), "{}", fs.glsl);
|
||||
assert!(
|
||||
fs.glsl.contains("uniform samplerShadow iris_compare;"),
|
||||
"{}",
|
||||
fs.glsl
|
||||
);
|
||||
assert_eq!(fs.interface.sampler_vars["colortex0"], "iris_linear");
|
||||
// Three sampler bindings for four textures.
|
||||
assert_eq!(
|
||||
bindings(&fs).keys().filter(|k| !k.ends_with("_t")).count(),
|
||||
3,
|
||||
"{:?}",
|
||||
bindings(&fs)
|
||||
);
|
||||
}
|
||||
+1
-2
@@ -12,7 +12,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Agent control](agent-control.md): what AI agents can drive in the launcher and the game, and the design of the control socket, `horizon-ctl` and MCP.
|
||||
- [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured.
|
||||
- [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it.
|
||||
- [Shader packs](shader-packs.md): how the renderer draws a frame, the Horizon WGSL shader pack format (stages, uniforms, `pack.toml`, hot reload), and the plan to translate OptiFine/Iris packs.
|
||||
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
|
||||
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods, and [what carries over from Java mods](research/java-mod-compat.md).
|
||||
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
|
||||
@@ -21,7 +20,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Resource packs](resource-packs.md): everything a 26.3 resource pack can change, whether Horizon honours it, and the proof.
|
||||
- [Opt-in data collection](telemetry.md): the consent, exactly what is sent, how it is stored and sent.
|
||||
- [Benchmark report format](benchmark-report.md): the versioned JSON every benchmark writes (game, launcher, `tools/perf_bench.py`), the local log and, with consent, the upload.
|
||||
- Menus: every screen is vanilla's (title, world list, create world, multiplayer, options, pause). Horizon's own settings hang off vanilla's Options the way mods add theirs: **Horizon Settings...** and **Mods...** in the last two slots of the Options grid (Horizon Settings opens Graphics, Advanced Graphics, Shader Packs, Controller Settings, Extras and Mods, each a vanilla options list), with Horizon Graphics, Advanced Graphics and Shader Packs also at the top of Video Settings and Controller Settings at the bottom of Controls. The title screen's and pause menu's Friends buttons open Join a Friend and Play with Friends ([play-with-friends.md](play-with-friends.md)).
|
||||
- Menus: every screen is vanilla's (title, world list, create world, multiplayer, options, pause). Horizon's own settings hang off vanilla's Options the way mods add theirs: **Horizon Settings...** and **Mods...** in the last two slots of the Options grid (Horizon Settings opens Graphics, Advanced Graphics, Controller Settings, Extras and Mods, each a vanilla options list), with Horizon Graphics and Advanced Graphics also at the top of Video Settings and Controller Settings at the bottom of Controls. The title screen's and pause menu's Friends buttons open Join a Friend and Play with Friends ([play-with-friends.md](play-with-friends.md)).
|
||||
|
||||
## Parity
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ passes on without checking it).
|
||||
| Patch notes, links | Patch notes, footer | yes | yes | yes | yes | |
|
||||
| Game log, crash reports | Settings | yes | yes | yes | yes | `logs [--crash]` |
|
||||
| Data collection: status, allow, deny, show, delete, send, bug, benchmark upload | Settings > Data | yes | part | yes | yes | sending was missing in MCP; `data_delete` needs `confirm` |
|
||||
| Mod browser: search, check, install, update, remove, list (mods, resource packs, shader packs, modpacks) | Mods | yes | no | yes | yes | `mods ...`; `mods_remove` needs `confirm` |
|
||||
| Mod browser: search, check, install, update, remove, list (mods, resource packs, modpacks) | Mods | yes | no | yes | yes | `mods ...`; `mods_remove` needs `confirm` |
|
||||
| Modpacks: import, port, play, remove, list | Modpacks | yes | no | yes | yes | `modpack ...`; `modpack_remove` needs `confirm` |
|
||||
| Launcher benchmark | (none) | yes | no | yes | yes | |
|
||||
| The game it started | (the game) | (horizon-ctl) | yes | (horizon-ctl) | yes | `game_*` |
|
||||
@@ -101,10 +101,10 @@ passes on without checking it).
|
||||
| Open to LAN | Pause > Open to LAN | part | no | yes | yes | `lan open\|close\|status` |
|
||||
| Play with Friends: host, stop, status | Pause | part | no | yes | yes | `friends host\|stop\|status` |
|
||||
| Vanilla options, every page | Options | yes | part | yes | yes | `options-get`, `options-set` (all of options.txt, typed); `options PAGE` was socket-only |
|
||||
| Horizon Settings: Graphics, Shader Packs, Controller, Extras, Mods, AI Agent | Horizon Settings | part | part | yes | yes | `options horizon\|graphics\|extras\|agent...`, then `ui`/`click`/`set` |
|
||||
| Horizon Settings: Graphics, Controller, Extras, Mods, AI Agent | Horizon Settings | part | part | yes | yes | `options horizon\|graphics\|extras\|agent...`, then `ui`/`click`/`set` |
|
||||
| Agent control on or off, status, connection details | Horizon Settings > AI Agent | no | no | yes | yes | new page (#1353) |
|
||||
| Key binds | Controls > Key Binds | yes | yes | yes | yes | `options-set key_key.jump key.keyboard.j` |
|
||||
| Resource packs, shader packs | Options > Resource Packs, Shader Packs | part | part | yes | yes | `options packs`, `options shader-packs`, then `ui` rows and `click`; `options-set resourcePacks` |
|
||||
| Resource packs | Options > Resource Packs | part | part | yes | yes | `options packs`, then `ui` rows and `click`; `options-set resourcePacks` |
|
||||
| Chat and commands | Chat | yes | yes | yes | yes | `chat`, `command`, `messages`, `chat-log` |
|
||||
| Inventory, containers, stations | Inventory, Chest... | yes | part | yes | yes | `inventory`, `screen`, `click-slot`; `container X Y Z` was socket-only |
|
||||
| Crafting, recipe book | Crafting | part | part | yes | yes | the grid by `click-slot`; the recipe book by `ui`/`click` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice
|
||||
- **Launcher** (`launcher/`): drawn in the Blocks look below. A top bar (wordmark, the pages as
|
||||
tabs, the account chip, Settings), a sidebar (the instances, the hosted servers, the launcher's
|
||||
own version and channel at its foot), and the page over a stone texture. Home opens on a pixel
|
||||
landscape banner with the hero, the version picker and Play; Mods holds the resource pack,
|
||||
shader pack and modpack tabs; Versions and Patch notes are lists of panels; Settings is
|
||||
landscape banner with the hero, the version picker and Play; Mods holds the mod, resource pack
|
||||
and modpack tabs; Versions and Patch notes are lists of panels; Settings is
|
||||
grouped panels of rows. Dark by default, Light or System in Settings (Appearance).
|
||||
- **Website** (`website/`): the Blocks look below. The same page structure as before (what it
|
||||
is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ Horizon category in Controls, and adding one would put a non-vanilla mapping int
|
||||
|
||||
### Dynamic lights (`dynamicLights`, default off)
|
||||
- A light source held by a player or mob (main or off hand), dropped as an item, or an entity on
|
||||
fire lights the blocks, entities and hand around it, as LambDynamicLights and OptiFine do. Not
|
||||
fire lights the blocks, entities and hand around it, as dynamic-light mods do. Not
|
||||
in vanilla; the idea is ported, not a mod's code (issue #814).
|
||||
- An item glows like its block's default state (`getLightEmission`): torches 14, lanterns,
|
||||
glowstone, sea lanterns and jack o'lanterns 15, soul torches 10, a redstone torch 7; a lava bucket
|
||||
|
||||
+1
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
## Settings
|
||||
|
||||
Horizon's graphics settings are two screens built from vanilla's widgets, opened from the top of **Video Settings** (**Horizon Graphics...** and **Advanced Graphics...**, beside **Shader Packs...**, where Sodium and Iris put theirs) or from **Options → Horizon Settings...**:
|
||||
Horizon's graphics settings are two screens built from vanilla's widgets, opened from the top of **Video Settings** (**Horizon Graphics...** and **Advanced Graphics...**) or from **Options → Horizon Settings...**:
|
||||
|
||||
- **Graphics** (Simple): a preset (Performance, Balanced, Quality, Ultra or Custom) and LOD distance.
|
||||
- **Advanced Graphics**: every Horizon setting, grouped under Rendering, Quality, Display, Preferences, Performance and Overlay.
|
||||
@@ -45,8 +45,6 @@ Coverage and detail improve progressively; a large selected radius does not mean
|
||||
|
||||
Ordinary Overworld atmospheric fog extends with the selected LOD distance. Fog Off disables distance haze at large ranges too; fluid, darkness and boss fog retain their limits.
|
||||
|
||||
LOD terrain enters the existing world colour and depth passes, so supported shader-pack postprocessing can see it. This does not add Iris material passes or shadow maps: Java packs still have the [existing fullscreen-pass limitations](shader-packs.md). Shader appearance and transitions need testing with the selected pack.
|
||||
|
||||
The setting is stored as `lodDistance` in `horizon-graphics.txt`; `0` disables it. Existing `renderDistance` in `options.txt` keeps its normal meaning for full chunks.
|
||||
|
||||
Not implemented: anisotropic filtering (wgpu needs linear magnification for it, which blurs close blocks), MSAA (FXAA instead) and exclusive fullscreen (fullscreen is borderless).
|
||||
|
||||
@@ -94,7 +94,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
|
||||
| `respawn` | `respawn` | Respawn from the death screen | none |
|
||||
| `pause` | `pause` | Open the pause menu | none |
|
||||
| `resume` | `resume` | Leave the pause menu | none |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, graphics, graphics-advanced, extras, horizon, shader-packs, agent): the page (default: the main options) |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
|
||||
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
|
||||
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
|
||||
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# Java performance packs (no longer developed)
|
||||
|
||||
Before the Rust rewrite, Horizon was a lab for Minecraft Java client performance: pinned Fabric performance packs, distant terrain, an original frame and tick recorder, and client and server starter downloads (the server starter is gone: [horizon-server](hosting.md) replaces it). This track is **no longer developed**. No release downloads are published; build the packs and starters locally with `python3 tools/build_release.py` (see [Build and run](#build-and-run)). This page keeps its documentation.
|
||||
|
||||
## Java packs: Mac starter (out of scope)
|
||||
|
||||
Build **horizon-mac-starter-0.21.0.zip** with `python3 tools/build_release.py` (it lands in `dist/`), extract it and open `Mac-Start.command`. It verifies and installs official universal Prism Launcher in `~/Applications` if needed, then opens the balanced pack import. Sign into Microsoft **inside Prism yourself**. This project needs no credentials.
|
||||
|
||||
Use native **Java 25 ARM64**, maximum heap **6144 MiB**, minimum **512 MiB** on a 16 GB M2 Pro Mac. Prism can download Java automatically. Start at 1280×800 or 1920×1200; full Retina resolution is a larger GPU workload. [START-HERE.txt](../packs/START-HERE.txt) has details. The same `.mrpack` files import into Prism on Windows and Linux.
|
||||
|
||||
Core and balanced passed macOS title and demo-world rendering probes on the hosted
|
||||
ARM64 software renderer, including readback, saves and clean exits. Physical M2
|
||||
GPU performance and macOS 27 were never tested.
|
||||
|
||||
| Profile | Full-detail chunks | Distant LOD radius | Suggested maximum heap |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| baseline | 12 | none | 6144 MiB |
|
||||
| core-performance | 12 | none | 6144 MiB |
|
||||
| m2-balanced | 12 | 128 | 6144 MiB |
|
||||
| adaptive (experiment) | 12 | 128 | 6144 MiB |
|
||||
| worker-pacing (experiment) | 12 | 128 | 6144 MiB |
|
||||
| adaptive-pacing (experiment) | 12 | 128 | 6144 MiB |
|
||||
| queue-wait (experimental) | 12 | 128 | 6144 MiB |
|
||||
| adaptive-queue (experimental) | 12 | 128 | 6144 MiB |
|
||||
| dispatch-retry (experimental) | 12 | 128 | 6144 MiB |
|
||||
| tick-dedup (experimental) | 12 | 128 | 6144 MiB |
|
||||
| culling-empty (experimental) | 12 | 128 | 6144 MiB |
|
||||
| quad-reuse (experimental) | 12 | 128 | 6144 MiB |
|
||||
| terrain-position (experimental) | 12 | 128 | 6144 MiB |
|
||||
| culling-rays (experimental) | 12 | 128 | 6144 MiB |
|
||||
| foreground-first (combined experiment) | 12 | 128 | 6144 MiB |
|
||||
| foreground-vulkan (combined experiment) | 12 | 128 | 6144 MiB |
|
||||
| low-memory | 8 | none | 3072 MiB |
|
||||
| far-distance | 12 | 256 | 8192 MiB, with sufficient physical RAM |
|
||||
| cinematic | 12 | 128 | 6144 MiB |
|
||||
| vulkan | 12 | 128 | 6144 MiB |
|
||||
|
||||
Balanced combines [Sodium](https://modrinth.com/mod/sodium), [Lithium](https://modrinth.com/mod/lithium), [FerriteCore](https://modrinth.com/mod/ferrite-core), [ImmediatelyFast](https://modrinth.com/mod/immediatelyfast), [EntityCulling](https://modrinth.com/mod/entityculling), and [Distant Horizons](https://modrinth.com/mod/distanthorizons). Cinematic adds [Iris](https://modrinth.com/mod/iris) and [Complementary Reimagined](https://modrinth.com/shader/complementary-reimagined), initially disabled. Vulkan independently experiments with the game's own backend and fallback, without Iris.
|
||||
|
||||
Distant LOD is simplified terrain. Nearby blocks retain full detail. Initial generation costs CPU/disk/memory and needs warm-up. DH adjusts clouds, chunk fade and transparency to blend its renderer; assess appearance as well as speed. Shaders cost GPU time and have [Mac-specific limits](https://shaders.properties/current/reference/miscellaneous/macos/). Universal CPU/GPU support and exact vanilla equivalence remain validation goals. Simulation distance stays 12 in every profile; normal 20 TPS rules are retained.
|
||||
|
||||
For the first comparison at the same view distance, use `baseline` versus
|
||||
`core-performance`: both select 12 full-detail chunks and no distant LOD or shaders.
|
||||
The latter adds only the pinned core performance mods and leaves every original
|
||||
controller disabled. Then test balanced separately for its additional distant
|
||||
terrain. Matching captured settings does not prove pixel-identical rendering.
|
||||
|
||||
The profiles marked *experiment* each isolate one change to Distant Horizons,
|
||||
EntityCulling or the JVM (adaptive terrain throttling, precise worker pacing,
|
||||
debounce-queue waits, rejected-dispatch retries, empty culling whitelists,
|
||||
culling ray scratch vectors, terrain quad reuse, terrain position arithmetic,
|
||||
tick membership storage, a smaller heap, compact object headers). Each kept the
|
||||
upstream behaviour it replaced and was measured only on Linux software rendering,
|
||||
where none showed a gain large enough to enable by default; balanced keeps them
|
||||
all off. Their write-ups were removed once the Rust rewrite replaced this track;
|
||||
they remain in Git history. Matched software-rendered pairs showed `core-performance`
|
||||
14–21% faster than `baseline`, and Vulkan about 1.5% more FPS for about 12% more
|
||||
CPU; neither is evidence for a Mac GPU.
|
||||
|
||||
To try the game's own Vulkan backend on a Mac, see [Vulkan on your Mac](vulkan.md);
|
||||
to compare garbage collectors, see [collector comparisons](collectors.md).
|
||||
|
||||
## Measure on your hardware
|
||||
|
||||
Press **F8** during gameplay to start a 120-second recording, or again to stop early. Rebind in Controls if needed. This also works with restricted chat accounts. Alternatively: `/horizon bench <label> 120`, `/horizon stop`, `/horizon status`.
|
||||
|
||||
Use separate identical world copies and the same camera route, resolution, render/simulation distance, heap, VSync and FPS limit. Warm up, then alternate at least three baseline/optimized runs. Test fresh chunk exploration separately. For everyday play cap FPS or enable VSync to reduce power usage.
|
||||
|
||||
The instance's `horizon-benchmarks/` folder receives JSON/CSV frame and integrated-server tick timings: average FPS, slowest-1%-average FPS, p95/p99 durations, stalls, JVM heap/GC, sampled process RSS when available, GPU/backend/driver and render settings. Frames include presentation/limiting waits and exclude menus/pauses/unfocused time. Tick `meanRate` is execution capacity, **not TPS**; observed tick counts are separate. Version 0.20 [captures sampled tick-manager state](tick-state.md), including restored freeze/step/sprint/rate changes; this does not measure completed world work. Resident process memory is sampled on a shared background thread only during recordings; it includes native resident pages, while heap measures JVM objects. Neither is a complete measurement of unified GPU memory. [Memory measurement details](process-memory.md).
|
||||
|
||||
```sh
|
||||
python3 tools/compare_benchmarks.py baseline-frames.json optimized-frames.json --strict
|
||||
```
|
||||
|
||||
Open `Compare-Benchmarks.html` from the starter ZIP or [the source file](../tools/Compare-Benchmarks.html) in your browser to compare without Python. Select each JSON and optionally its matching CSV. It exports summary JSON, flags settings and duration differences, and charts frames by recorded active time or ticks by sample number. It is self-contained and makes no network requests.
|
||||
|
||||
|
||||
|
||||
The comparison flags captured setting differences, but cannot verify route, world, shader state or cache warm-up. Software-renderer test FPS is not evidence of a Mac/GPU speedup.
|
||||
|
||||
For CPU/allocation investigation, [capture and stream a private JFR summary](profiling.md).
|
||||
It distinguishes directly observed allocation counters from sampled hot methods;
|
||||
profiling runs remain separate from normal timing comparisons.
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires Python 3.10+ and JDK 25. No Gradle, Rust or native compilation is required for playable packs.
|
||||
|
||||
```sh
|
||||
python3 tools/build_mod.py
|
||||
python3 tools/build_release.py
|
||||
```
|
||||
|
||||
Gitignored `dist/` contains twenty packs, our original instrumentation JAR, the client starter ZIP and SHA256SUMS. [mods.lock.json](../packs/mods.lock.json) pins mod versions, exact dependency closure, SHA-1/SHA-512 and source/license; [fabric.lock.json](../packs/fabric.lock.json) pins loader/library SHA-256. Packs use the [Modrinth format](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack). They embed only original project code; third-party mods, game and assets download from their publishers.
|
||||
|
||||
The Fabric server starter (Lithium/FerriteCore, `tools/server.py`) was retired on 2026-10-19. To host a server, use [horizon-server](hosting.md) ([playhorizon.dev/server](https://playhorizon.dev/server)); its benchmarks are in [server.md](server.md), section 9.
|
||||
|
||||
Local smoke testing uses the game's built-in limited demo; normal play uses Prism's licensed account authentication:
|
||||
|
||||
```sh
|
||||
python3 tools/build_pack.py --profile m2-balanced --instance .local/runtime/m2-balanced
|
||||
vscreen run python3 tools/runtime.py demo --instance .local/runtime/m2-balanced
|
||||
```
|
||||
|
||||
The standalone demo launcher applies the pinned official manifest's conditional
|
||||
JVM flags, including macOS `-XstartOnFirstThread` and the Windows heap-dump-path
|
||||
option. Prism applies its own official manifest. Server JVM policy is separate;
|
||||
this launcher correction is not a measured graphics performance improvement.
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
Every game release lists the bullets below under "Known issues" (tools/release_train.py). Keep them short and
|
||||
written for players; remove a bullet in the pull request that fixes it.
|
||||
|
||||
- Shader packs have been removed for now. They will come back later, written again from scratch. Packs in your
|
||||
`shaderpacks` folder are left where they are.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Start the client.
|
||||
- `--world`: Open or create this world (a folder under saves) at once
|
||||
- `--creative`: Creative mode for a new world
|
||||
- `--join`: Join a friend's world with its invite code
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --in-frame
|
||||
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
|
||||
|
||||
MCP: `play`, then the `game_*` tools.
|
||||
|
||||
@@ -143,7 +143,7 @@ MCP: `settings`.
|
||||
|
||||
## `horizon-launcher game-args`
|
||||
|
||||
Show or set the extra arguments the game starts with (for example --in-frame); quotes group a word with spaces in it.
|
||||
Show or set the extra arguments the game starts with (for example --server-thread); quotes group a word with spaces in it.
|
||||
|
||||
horizon-launcher game-args [ARGS]
|
||||
|
||||
@@ -321,7 +321,7 @@ Search for packs Horizon loads.
|
||||
horizon-launcher mods search [TEXT]...
|
||||
|
||||
- `TEXT`
|
||||
- `--kind` (mod, resource, shader, modpack)
|
||||
- `--kind` (mod, resource, modpack)
|
||||
- `--everything`: Also what was made for other versions or runs only partly, each marked
|
||||
- `--source` (modrinth, curseforge)
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 278 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 40 KiB |
+3
-6
@@ -15,11 +15,9 @@ yet (Modrinth has no `horizon` loader).
|
||||
|---|---|---|
|
||||
| Java mods (Fabric, NeoForge, Forge, Quilt) | ported into `horizon-mods/<mod id>/` and turned on | Checked from its jar before install: **Works**, **Partly** or **Doesn't run** ([below](#java-mods)) |
|
||||
| Resource packs | `resourcepacks/` beside `options.txt` | **Works** when made for 26.3 ([resource-packs.md](resource-packs.md) lists what Horizon reads). Older packs are **Partly**: they load with vanilla's compatibility warning |
|
||||
| Shader packs (Iris, OptiFine) | `shaderpacks/` | **Partly**: translated to WGSL, the full-screen passes draw and the world and shadow passes don't yet ([shader-packs.md](shader-packs.md)). Packs for other loaders (Canvas, core-shader packs) **don't run** and can't be installed |
|
||||
| Modpacks (`.mrpack`, CurseForge zip) | the Modpacks page's import | **Partly**: Horizon runs the mods it can port, from the player's own downloads ([modpacks.md](modpacks.md)) |
|
||||
|
||||
By default a search shows only what runs: Java mods made for 26.3, resource packs made for 26.3, shader packs for Iris
|
||||
or OptiFine, and every modpack. **Show everything** lists the rest of the kind too, each marked with how well it runs; what doesn't run is
|
||||
By default a search shows only what runs: Java mods made for 26.3, resource packs made for 26.3, and every modpack. **Show everything** lists the rest of the kind too, each marked with how well it runs; what doesn't run is
|
||||
never offered for install.
|
||||
|
||||
## Java mods
|
||||
@@ -76,7 +74,7 @@ start); the hedge places and its recipe crafts 4.
|
||||
|
||||
## The Mods page
|
||||
|
||||
The launcher's **Mods** page has the four kinds as tabs (Mods, Modpacks, Resource packs, Shader packs), a search field (Enter searches), **Show everything** and
|
||||
The launcher's **Mods** page has the three kinds as tabs (Mods, Modpacks, Resource packs), a search field (Enter searches), **Show everything** and
|
||||
the source: Modrinth, with **CurseForge (coming soon)** beside it, disabled. Each result is an index row: its summary, author,
|
||||
downloads, source and licence, how well it runs (and why), **Page** (the project's page on its source) and
|
||||
**Install** (**Import** for a modpack). The Modpacks tab lists the imported packs first, with their coverage
|
||||
@@ -127,8 +125,7 @@ same name the player put there is never overwritten.
|
||||
|
||||
- **Install** picks the newest version that runs, then its required dependencies, checked before any download: a
|
||||
dependency that isn't a pack Horizon loads (a Java mod) stops the install. Dependencies go in first. A resource
|
||||
pack still has to be turned on in the game's Resource Packs screen, and a shader pack in Video Settings > Shader
|
||||
Packs, as in vanilla and Iris.
|
||||
pack still has to be turned on in the game's Resource Packs screen, as in vanilla.
|
||||
- **Update** installs the newer version and removes the old file.
|
||||
- **Remove** deletes the file and its entry. Packs installed as dependencies stay.
|
||||
- **A modpack** is downloaded (checked the same way) into `ports/downloads/`, imported, and ported straight away
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ was downloaded.
|
||||
| **library** | APIs and code shared by other mods (Architectury, Curios, GeckoLib, Kotlin for Forge, Balm...) | no: maps to the Horizon API or the SDK | **80** |
|
||||
| **integration** | glue between two other mods (JEI plugins, AE2 ↔ Mekanism, Create add-on wiring) | after both sides are ported | **44** |
|
||||
| **client-ui** | client-only screens, HUDs, tooltips, maps, recipe viewers | yes: client API, or built in | **32** |
|
||||
| **other** | pack tooling and engine features: KubeJS, Almost Unified, Iris/shaders, CTM, crash/version tools | no: folded into the pipeline or the engine | **25** |
|
||||
| **other** | pack tooling and engine features: KubeJS, Almost Unified, shader mods, CTM, crash/version tools | no: folded into the pipeline or the engine | **25** |
|
||||
| **performance** | Sodium, FerriteCore, ModernFix, ImmediatelyFast, spark, AI and recipe speed-ups | no: Horizon is the replacement | **16** |
|
||||
| **server-only** | admin and server features: ranks, essentials, backups, claims, spawn control | yes: server API | **11** |
|
||||
| | | | **495** |
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3.
|
||||
| Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model |
|
||||
| Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new / build / run` |
|
||||
| In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods |
|
||||
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, shader packs, modpacks |
|
||||
| Launcher mod browser (Modrinth, CurseForge with a key) | [mod-browser.md](mod-browser.md) | resource packs, modpacks |
|
||||
| `.hmod` index, signing, server mods, registries | this page, [Roadmap](#roadmap) | planned |
|
||||
|
||||
## Design goals
|
||||
|
||||
@@ -134,7 +134,7 @@ interfaces listed in [mods.md](../mods.md#planned-interfaces).
|
||||
| **Mouse Tweaks / Inventory Profiles Next** | drag and sort inventory | built in or SDK mod | `inventory-read` + a sanctioned click API |
|
||||
| **Continuity / CIT** | connected and custom item textures | built into the renderer (pack formats) | none: resource-pack features |
|
||||
| **Distant Horizons** | low-detail far terrain | a Horizon renderer feature, not a mod | none |
|
||||
| **Iris + shader packs** | shaders | a Horizon renderer feature (wgpu shader pipeline), later | none |
|
||||
| **Shader packs** | shaders | removed for now; a clean-room Horizon renderer feature, later | none |
|
||||
| **Simple Voice Chat** | proximity voice | server + client mod | `server`, `net`, audio (later) |
|
||||
|
||||
### 3. A porting guide (outline)
|
||||
|
||||
@@ -71,7 +71,7 @@ harness named checks that loader against vanilla).
|
||||
| Paintings, mob effect icons | `atlases/paintings.json`, `textures/mob_effect/` | works | loaded from the overlay |
|
||||
| Locator bar styles | `waypoint_style/` | **no** | the locator bar isn't drawn yet |
|
||||
| Post effects (spectating a creeper, spider or enderman; glowing outlines) | `post_effect/`, `shaders/post/` | **no** | Horizon's effects are WGSL; a pack's GLSL post chain isn't run |
|
||||
| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; packs that replace core shaders need the GLSL translation of [shader-packs.md](shader-packs.md) |
|
||||
| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; a pack's GLSL core shaders aren't run |
|
||||
| GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used |
|
||||
|
||||
Summary: 44 rows; 31 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
|
||||
@@ -90,7 +90,7 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p
|
||||
| Trims on worn armour (paletted textures) | medium | [#337](https://github.com/ludovic111/horizon/issues/337) |
|
||||
| Credits and end poem screen | medium | [#338](https://github.com/ludovic111/horizon/issues/338) |
|
||||
| Locator bar | medium | [#339](https://github.com/ludovic111/horizon/issues/339) |
|
||||
| Post effects and core shaders | large | [shader-packs.md](shader-packs.md) |
|
||||
| Post effects and core shaders | large | none yet |
|
||||
|
||||
## Testing a pack
|
||||
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
# Shader packs
|
||||
|
||||
Horizon will run two kinds of shader packs:
|
||||
|
||||
- **Horizon shader packs**: WGSL, a `pack.toml` manifest and documented stages. Horizon's own format, made for wgpu,
|
||||
with hot reload while you edit.
|
||||
- **Java shader packs** (OptiFine/Iris): the `shaders/` packs players already have, translated from GLSL to WGSL
|
||||
when they load.
|
||||
|
||||
Both are off by default. With no pack selected the renderer draws exactly what it draws today, which is vanilla
|
||||
26.3's picture: no extra pass, no extra target, the same pixels.
|
||||
|
||||
| Part | State |
|
||||
| --- | --- |
|
||||
| Renderer layout (below) | today's code |
|
||||
| Horizon shader packs: `composite` stage, uniforms, hot reload, Shader Packs page | done (the page: list, pick, open the folder, drop a pack on it) |
|
||||
| Horizon shader packs, api 2: stage `format`s, `[profiles]`, option descriptions, a `noise` texture, the opaque depth, a G-buffer reconstructed from depth, a sun/moon shadow map (Stages, below) | first slice: `examples/shaderpacks/horizon-ultra` |
|
||||
| Horizon shader packs: `deferred`, `gbuffer`, `shadow` stages (the pack's own code in the world pass) | planned |
|
||||
| Shader Pack Settings page: a pack's options, sub-screens, sliders, profiles and Reset, Horizon and Java packs alike; applied once on Done, as Iris does | done |
|
||||
| Java shader packs: preprocessing + GLSL parse coverage | spike done: 450 of 470 programs reach valid WGSL (below) |
|
||||
| Java shader packs: preprocessor (`crates/horizon-shaderpack`: includes, options, Iris's macros, JCPP's `#if` and `#define` rules, `.lang` files) | done: every program of the three test packs preprocesses; ten packs tested (Compatibility, below) |
|
||||
| Java shader packs: GLSL → WGSL (`horizon_shaderpack::translate`: syntax-tree legaliser, naga; struct and nested-array varyings, vertex `texture`, `mat2(vec4)`, `1 << 31`) | done: the 464 programs the three test packs enable by default translate (below) |
|
||||
| Java shader packs: `shaders.properties` (`horizon_shaderpack::properties`: screens, sliders, profiles, `program.*.enabled`, image formats, blend modes, `texture.*`) | done: read like Iris for the three test packs |
|
||||
| Java shader packs: full-screen pass plan (`horizon_shaderpack::pipeline`: `DRAWBUFFERS`/`RENDERTARGETS`, `colortexN` formats and clearing, buffer flips, `blend.*`) | done: planned for the three test packs |
|
||||
| Java shader packs: full-screen passes drawn (`java_pack.rs`, `gfx/java_stages.rs`: `begin`, `prepare`, `deferred*`, `composite*`, `final` over Horizon's world) | first slice: BSL and Complementary run (below); `depthtex1`/`depthtex2` before translucent geometry, `blend.*`, PNG `texture.<stage>.*` and `texture.noise` |
|
||||
| Java shader packs: custom uniforms (`horizon_shaderpack::uniforms`), `sunPathRotation`, `clouds`/`sun`/`moon`, eye light, `wetness` | done for the full-screen passes (below) |
|
||||
| Java shader packs: `gbuffers_terrain`, `gbuffers_water` (`gfx/java_terrain.rs`: Horizon's terrain fed to the pack's programs) | first slice: MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water (below) |
|
||||
| Java shader packs: `shadow` programs (`gfx/java_stages.rs`: the shadow map from Horizon's terrain) | first slice: terrain shadows for MakeUp Ultra Fast, Chocapic13' and BSL (below) |
|
||||
| Java shader packs: other `gbuffers_*`, entity shadows, `shadowcomp`, `.dat` and resource-location textures, images, compute | planned |
|
||||
|
||||
## How the renderer draws a frame today
|
||||
|
||||
Everything is in `crates/horizon-client/src/gfx.rs`, `Renderer::render`, one command encoder per frame:
|
||||
|
||||
1. **World pass** (`"Horizon world"`): clears colour to the fog colour (black on the title screen and in the End)
|
||||
and depth to 0 (reversed depth, `Depth32Float`, `GreaterEqual`; `camera::perspective`). In order: the sky
|
||||
(`sky` pipeline, `terrain.wgsl`), the title panorama (`gfx/panorama.rs`) or the sun, moon and stars
|
||||
(`celestial_pipeline`), the dark disc; solid and cutout terrain (`solid`, one indirect multi-draw over the
|
||||
section arena, `gfx/arena.rs`); entities and block entities (`entity_pipeline`, `entity.wgsl`, batched by
|
||||
`gfx/entity_batch.rs`); block breaking (`crumbling_pipeline`); additive geometry (lightning, dragon beams);
|
||||
the block outline and debug lines; translucent terrain (`translucent`); the world border (`border.wgsl`);
|
||||
translucent crumbling; clouds (`cloud_pipeline`). Weather and particles are entity quads. Depth is
|
||||
discarded at the end of the pass.
|
||||
2. **World resolve** (`gfx/world_post.rs`, `world_post.wgsl`): only when resolution scale isn't 100% or FXAA is
|
||||
on, the world pass draws into its own colour and depth targets, and one full-screen pass copies or
|
||||
anti-aliases them onto the screen.
|
||||
3. **Hand** (`"First-person hand"`): the first-person hand and held item, with depth cleared, at the hand FOV.
|
||||
4. **Screen effects** (`screen_effects.rs`): pumpkin blur, portal, powder snow, vignette, nausea, as
|
||||
full-screen quads.
|
||||
5. **Menu blur** (`menu_blur.rs`, `menu_blur.wgsl`): vanilla's `menuBackgroundBlurriness` behind screens.
|
||||
6. **HUD** (`hud.rs`, `hud.wgsl`): GUI, text, items in slots.
|
||||
|
||||
All world shaders share one uniform block, `Globals` in `gfx.rs` (`struct Globals` in `terrain.wgsl`):
|
||||
view-projection, inverse view, fog colour and distances, sky colour, sun direction, sunrise colour, game time.
|
||||
The lightmap, fog and sky colours are computed on the CPU (`environment.rs`, `fog.rs`) as vanilla does.
|
||||
|
||||
## Horizon shader packs
|
||||
|
||||
### Where packs live and how one is picked
|
||||
|
||||
- Packs go in `shaderpacks/` beside `options.txt` in the game folder (the same folder Iris uses, so one folder
|
||||
holds both kinds). That is the game home the launcher starts the game in (`%APPDATA%\horizon` on Windows,
|
||||
`~/Library/Application Support/horizon` on macOS, `~/.local/share/horizon` on Linux), not the launcher's version
|
||||
folder. A pack with a `pack.toml` at its root is a Horizon pack; a folder with `shaders/`, or a `.zip`, is a Java
|
||||
pack.
|
||||
- The **Shader Packs** page lists them: **Video Settings → Shader Packs...** (where Iris puts its button), **Options →
|
||||
Horizon Settings... → Shader Packs...**, and the Shader Pack row of the graphics settings lead to it. It shows the folder's full path
|
||||
and an *Open Folder* button (the folder is made if it is missing), then one switch per pack: turning one on picks
|
||||
it, turning it off is the vanilla picture. With no pack, it says so under the path. The list is read again every
|
||||
second while the page is open, so a pack dropped into the folder shows up. A `.zip` or pack folder dropped on the
|
||||
window while the page shows is copied into the folder and picked (Iris's `ShaderPackScreen.onFilesDrop`); one of
|
||||
that name already there is left alone.
|
||||
- With a pack on that has options, **Shader Pack Settings...** opens them: Horizon packs' `[options]` and
|
||||
`[profiles]`, Java packs' `#define`/`const` options laid out by `shaders.properties` (`screen`, `screen.<name>`,
|
||||
`sliders`, `profile.*`) and named by `lang/en_us.lang` (`option.*`, `value.*`, `screen.*`, `profile.*`, as Iris;
|
||||
without one, the option's own name). The values are saved and the pack built again once, when the page closes
|
||||
(Iris applies on Done), not on every click or slider step. A saved value an option doesn't list is ignored.
|
||||
- The choice is kept in `horizon-graphics.txt` (`shaderPack:<folder or zip name>`, empty = off). A pack's option
|
||||
values are kept in `shaderpacks/<pack>.txt`, as Iris does.
|
||||
- A pack that fails to load or compile leaves the vanilla picture (or the last pack that worked) on and says why on
|
||||
its row ("Failed", with the error), in chat and in the log. Nothing a pack does can stop the game from starting.
|
||||
- Benchmarks (`--benchmark`) and the parity harnesses never load a pack.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
my-pack/
|
||||
pack.toml
|
||||
shaders/
|
||||
composite.wgsl
|
||||
bloom.wgsl
|
||||
textures/
|
||||
noise.png # api 2: [textures] noise
|
||||
LICENSE
|
||||
```
|
||||
|
||||
```toml
|
||||
[pack]
|
||||
name = "Warm evenings"
|
||||
description = "A gentle grade and a soft vignette."
|
||||
authors = ["you"]
|
||||
version = "1.0.0"
|
||||
license = "MIT"
|
||||
api = 1 # the stage and uniform contract below
|
||||
|
||||
[[stage]]
|
||||
kind = "composite" # see Stages
|
||||
shader = "shaders/composite.wgsl"
|
||||
entry = "fs_main" # optional, default fs_main
|
||||
|
||||
[[stage]]
|
||||
kind = "composite" # stages of one kind run in file order
|
||||
shader = "shaders/bloom.wgsl"
|
||||
scale = 0.5 # optional: this stage's target size relative to the screen
|
||||
|
||||
[options.saturation]
|
||||
label = "Saturation"
|
||||
default = 1.15
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[options.vignette]
|
||||
label = "Vignette"
|
||||
default = true
|
||||
description = "Darkens the corners." # optional: the button's tooltip
|
||||
```
|
||||
|
||||
Api 2 (`api = 2`) adds:
|
||||
|
||||
```toml
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/lighting.wgsl"
|
||||
format = "rgba16float" # this stage's target: rgba8unorm, rgba16float or rgb10a2unorm; the last stage
|
||||
# always writes the screen's format, so earlier ones can keep HDR colour
|
||||
|
||||
[profiles.low] # the Profile button cycles these; "Custom" when the values match none
|
||||
vignette = false
|
||||
saturation = 1.0
|
||||
|
||||
[textures]
|
||||
noise = "textures/noise.png" # replaces the prelude's noise_texture (256x256 random RGB without it)
|
||||
```
|
||||
|
||||
Options become WGSL [pipeline-overridable constants](https://www.w3.org/TR/WGSL/#override-decls): the shader
|
||||
declares `override saturation: f32 = 1.15;` and `override vignette: bool = true;`, and the settings screen sets
|
||||
them. Numbers are `f32` (with `min`/`max`/`step`, a slider) or `i32` (`values = [...]`, a cycle button); `true`/`false`
|
||||
are `bool` (an on/off button). Changing an option rebuilds that stage's pipeline (milliseconds), which is the job
|
||||
OptiFine's `#define` options do, without text substitution.
|
||||
|
||||
### Stages
|
||||
|
||||
A stage is one full-screen fragment shader. Horizon supplies the vertex shader (one triangle) and, before the
|
||||
pack's source, a prelude with the bindings, the `Frame` uniform and helper functions, so a pack's file holds only
|
||||
its own code.
|
||||
|
||||
| Stage | Runs | Reads | Writes | State |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `composite` | after the world pass, before the hand | scene colour and depth, previous stage | the next scene colour | first slice |
|
||||
| `final` | after the hand and screen effects, before menus and HUD | colour with hand, depth | the screen | planned |
|
||||
| `deferred` | after opaque terrain and entities, before translucent ones | opaque colour, depth, G-buffers | opaque colour | planned |
|
||||
| `gbuffer` | terrain, entities, water, sky: a WGSL `material` function called by Horizon's shaders | vertex data, atlas | colour + G-buffers (normal, material) | planned |
|
||||
| `shadow` | the world from the sun, into a shadow map | vertex data | shadow depth | planned |
|
||||
|
||||
The first slice has `composite` only. A `composite` stage reads the scene, not the HUD, the hand or the menus,
|
||||
so a pack can't make text unreadable. Every stage after the first also reads the previous stage's output.
|
||||
|
||||
Bindings in group 0 (the prelude declares them):
|
||||
|
||||
| Binding | WGSL | What |
|
||||
| --- | --- | --- |
|
||||
| 0 | `scene_color: texture_2d<f32>` | the world's colour (sRGB-encoded, as on screen) |
|
||||
| 1 | `scene_depth: texture_depth_2d` | the world's depth (reversed: 1 near, 0 sky) |
|
||||
| 2 | `previous: texture_2d<f32>` | the previous `composite` stage's output; `scene_color` for the first |
|
||||
| 3 | `linear_sampler: sampler` | linear, clamp to edge |
|
||||
| 4 | `nearest_sampler: sampler` | nearest, clamp to edge |
|
||||
| 5 | `frame: Frame` (uniform) | below |
|
||||
|
||||
```wgsl
|
||||
struct Frame {
|
||||
view: mat4x4<f32>, // world (camera-relative) to view
|
||||
projection: mat4x4<f32>, // view to clip, reversed depth
|
||||
inverse_view: mat4x4<f32>,
|
||||
inverse_projection: mat4x4<f32>,
|
||||
camera_position: vec4<f32>, // xyz: world position of the eye (f32, so ~1 mm precision at 30 000 blocks)
|
||||
resolution: vec4<f32>, // width, height, 1/width, 1/height of this stage's target
|
||||
time: vec4<f32>, // seconds since the pack loaded, frame counter, day fraction (0 = sunrise), partial tick
|
||||
sun: vec4<f32>, // xyz: direction to the sun (world space), w: sun angle in radians (0 at noon)
|
||||
moon: vec4<f32>, // xyz: direction to the moon, w: moon phase (0..7)
|
||||
sky_color: vec4<f32>, // vanilla's sky colour this frame
|
||||
fog_color: vec4<f32>, // vanilla's fog colour this frame (what the world pass clears to)
|
||||
fog: vec4<f32>, // environmental fog start, end; sky fog end; cloud fog end (blocks)
|
||||
weather: vec4<f32>, // rain level, thunder level, 0, 0 (smoothed as vanilla does)
|
||||
camera_state: vec4<f32>, // submerged in: 0 air, 1 water, 2 lava, 3 powder snow; night vision; darkness; blindness (0 for now)
|
||||
view_params: vec4<f32>, // near, far (Camera.depthFar), render distance (blocks), vertical FOV (radians)
|
||||
dimension: vec4<f32>, // 0 overworld, -1 nether, 1 end; has sky light; 0, 0
|
||||
}
|
||||
```
|
||||
|
||||
Prelude helpers: `linear_depth(depth) -> f32` (blocks from the eye), `view_position(uv, depth) -> vec3`,
|
||||
`world_offset(uv, depth) -> vec3` (camera-relative world position), `is_sky(depth) -> bool`, `luminance(rgb)`,
|
||||
`srgb_to_linear` / `linear_to_srgb`. They hide reversed depth, so a pack doesn't depend on it.
|
||||
|
||||
The uniform layout only grows: a new field goes at the end and the `api` number goes up; a pack that declares an
|
||||
older `api` still loads.
|
||||
|
||||
An `api = 2` pack's stages get a second prelude after the first (`shader_pack_prelude_api2.wgsl`; api 1 packs don't,
|
||||
so names they declare themselves can't clash):
|
||||
|
||||
| Binding | WGSL | What |
|
||||
| --- | --- | --- |
|
||||
| 6 | `frame_ext: FrameExt` (uniform) | last frame's view and projection and camera, the shadow map's matrices, resolution and distance, eye light, smoothed rain |
|
||||
| 7, 8 | `scene_normal`, `scene_material: texture_2d<f32>` | the G-buffer (below) |
|
||||
| 9 | `opaque_depth: texture_depth_2d` | the world's depth before translucent geometry (water, glass, ice) |
|
||||
| 10, 11 | `shadow_depth: texture_depth_2d`, `shadow_sampler: sampler_comparison` | the sun's (the moon's at night) shadow map |
|
||||
| 12, 13 | `noise_texture: texture_2d<f32>`, `repeat_sampler: sampler` | `[textures] noise`, linear, repeat |
|
||||
|
||||
Helpers: `opaque_depth_at(uv)`, `world_normal_at(uv)`, `material_at(uv)` (block light, sky light, translucent mask,
|
||||
roughness), `reproject_uv(world_offset)` (last frame's uv and depth), `sample_shadow(world_offset, normal)` (5-tap
|
||||
PCF, 1 lit .. 0 shadowed), `aces_tonemap(rgb)`.
|
||||
|
||||
What each costs, only while a stage reads it (naga reports the bindings each entry point uses): the opaque depth, a
|
||||
depth copy between the opaque and translucent halves of the world pass; the G-buffer, one full-screen pass after
|
||||
the world; the shadow map, a depth pass of the terrain and entities from the light. Limits of this first slice: the
|
||||
G-buffer is reconstructed from depth and colour (normals from neighbouring depths, block light estimated from
|
||||
brightness, sky light the camera's), not written by the world's shaders; the shadow map holds the sections the
|
||||
camera draws, so casters outside the view throw no shadow, and none of the terrain where Metal draws sections as one
|
||||
list (`gfx/flat.rs`, more than 4096 of them). The `deferred`, `gbuffer` and `shadow` stages above replace these.
|
||||
|
||||
### How it renders
|
||||
|
||||
With a pack on, the world pass draws into the world target of `gfx/world_post.rs` (it exists already for resolution
|
||||
scale and FXAA), with its depth stored and bindable. Then each `composite` stage draws into a ping-pong colour
|
||||
target, and the last output goes through the usual copy or FXAA onto the screen, under the hand, screen effects,
|
||||
menus and HUD. Without a pack none of this exists, and the frame is today's frame.
|
||||
|
||||
### Hot reload
|
||||
|
||||
While a pack is selected Horizon checks its files' modification times twice a second (a folder pack; a zip reloads
|
||||
when the zip changes). A change reparses `pack.toml`, compiles every stage with naga first (parse and validate, with
|
||||
the prelude), and swaps the pipelines only if all of them compiled; otherwise the last good pipelines stay on and the
|
||||
error (file, line, column, message) goes to chat and the log. F3 + T (vanilla's resource reload) reloads the pack
|
||||
too.
|
||||
|
||||
### Safety
|
||||
|
||||
A Horizon shader pack is WGSL only: it runs on the GPU, can't read files, the network or game memory, and naga
|
||||
validates it before wgpu sees it. A shader can still be slow or loop for long enough to make the GPU driver reset;
|
||||
that costs a frame or a device loss, which the renderer already handles. Limits: 16 stages, 64 options, stage
|
||||
targets at most the screen size times 2.
|
||||
|
||||
## Java shader packs (OptiFine/Iris)
|
||||
|
||||
### What a Java pack is
|
||||
|
||||
A `shaders/` folder of GLSL programs, named by where Iris (and OptiFine before it) runs them, plus
|
||||
`shaders.properties`:
|
||||
|
||||
- `shadow*`: the world from the sun into `shadowtex0/1` and `shadowcolor0/1`.
|
||||
- `gbuffers_*`: every kind of geometry (`basic`, `textured`, `textured_lit`, `skybasic`, `skytextured`, `clouds`,
|
||||
`terrain` (+ `_solid`, `_cutout`), `damagedblock`, `block`, `beaconbeam`, `item`, `entities`,
|
||||
`entities_glowing`, `armor_glint`, `spidereyes`, `hand`, `weather`, `water`, `hand_water`, `line`), with a
|
||||
fallback chain (a missing `gbuffers_terrain` uses `gbuffers_textured_lit`, then `gbuffers_textured`, then
|
||||
`gbuffers_basic`). They write up to 16 colour targets (`colortex0..15`, older names `gcolor`, `gdepth`,
|
||||
`gnormal`, `composite`, `gaux1..4`).
|
||||
- `deferred*` (after opaque geometry), `composite*` (after translucent), `final` (to the screen), and the
|
||||
compute programs `prepare`, `begin`, `setup` (`.csh`), all full-screen.
|
||||
- In the source: `/* DRAWBUFFERS:024 */` or `/* RENDERTARGETS: 0,2,4 */`, `const int colortex0Format = RGBA16F;`,
|
||||
`const int shadowMapResolution = 2048;`, `const float sunPathRotation = -40.0;` and many more constants Iris reads.
|
||||
- `shaders.properties`: the option screens and profiles, `program.<name>.enabled`, blend modes, custom textures
|
||||
(`texture.composite.colortex4 = ...`), custom uniforms (`uniform.float.x = <expression>`), per-dimension folders
|
||||
(`world0`, `world-1`, `world1`); `block.properties`, `item.properties` and `entity.properties` map block, item
|
||||
and entity ids to the numbers the shaders see in `mc_Entity`.
|
||||
- Options are `#define`s in the source (`#define SHADOW_QUALITY 2 // [1 2 3]`, `//#define BLOOM`), found by
|
||||
scanning the files.
|
||||
|
||||
### Translation
|
||||
|
||||
1. **Preprocess like Iris.** Resolve `#include` (absolute from `shaders/`, or relative), apply the user's option
|
||||
values to the `#define` lines, and define what Iris defines (`MC_VERSION`, `IS_IRIS`, `MC_GL_VERSION`,
|
||||
`MC_GLSL_VERSION`, `MC_OS_*`, `MC_GL_VENDOR_*`, `MC_GL_RENDERER_*`, `MC_RENDER_QUALITY`,
|
||||
`MC_SHADOW_QUALITY`, `MC_HAND_DEPTH`, the `DH_*`/feature flags as unsupported).
|
||||
2. **Legalise to GLSL 450 core.** Packs are written for `#version 120`..`460 compatibility` and use the
|
||||
compatibility profile: `gl_FragData`/`gl_FragColor`, `varying`/`attribute`, `texture2D`/`shadow2D`/
|
||||
`texture2DLod`, `gl_Vertex`, `gl_MultiTexCoord*`, `gl_Color`, `gl_Normal`, `gl_ModelViewMatrix`, `ftransform()`,
|
||||
loose `uniform float x;`. Iris rewrites the same things for the core profile (glsl-transformer). The legaliser
|
||||
turns them into explicit `layout(location)` inputs and outputs, one uniform block per stage, and
|
||||
separate texture + sampler bindings, which naga's GLSL front end (`naga::front::glsl`, Vulkan-style GLSL 440+)
|
||||
accepts.
|
||||
3. **naga GLSL → IR → WGSL**, validated, cached per pack, options and Horizon version under the game folder (only
|
||||
the translation, never the pack's files).
|
||||
4. **Emulate the pipeline.** Allocate the colour targets with the formats the pack asks for, run the programs in
|
||||
Iris's order with its blend and clear rules, feed the uniforms Iris provides (about a hundred:
|
||||
`gbufferModelView`, `gbufferProjection`, their inverses and previous-frame versions, `shadowModelView`,
|
||||
`shadowProjection`, `cameraPosition`, `previousCameraPosition`, `sunPosition`, `moonPosition`,
|
||||
`shadowLightPosition`, `sunAngle`, `worldTime`, `worldDay`, `moonPhase`, `frameTimeCounter`, `frameCounter`,
|
||||
`viewWidth`, `viewHeight`, `aspectRatio`, `near`, `far`, `rainStrength`, `wetness`, `eyeBrightness(Smooth)`,
|
||||
`isEyeInWater`, `nightVision`, `blindness`, `darknessFactor`, `fogColor`, `skyColor`, `heldItemId`,
|
||||
`heldBlockLightValue`, `biome`, ...), and give `gbuffers_*` the vertex attributes they read (`mc_Entity`,
|
||||
`mc_midTexCoord`, `at_tangent`, `at_midBlock`, lightmap coordinates, normals).
|
||||
|
||||
### What comes first, what is hard
|
||||
|
||||
| Piece | Difficulty | Why |
|
||||
| --- | --- | --- |
|
||||
| Preprocessor, options, `shaders.properties` screens | easy | text; no GPU |
|
||||
| Legaliser + naga parse | medium | many legacy built-ins and implicit conversions; the spike measures it |
|
||||
| `composite*` / `final` / `deferred*` | medium | full-screen passes, like Horizon's `composite`; need the colour targets and uniforms |
|
||||
| Uniforms | medium | most exist on the CPU already; previous-frame matrices, `eyeBrightnessSmooth`, `wetness` need tracking |
|
||||
| `gbuffers_*` for terrain | hard | Horizon's packed terrain vertices and arena multi-draw must also feed Iris's attributes and up to 8 render targets; `block.properties` ids per block state |
|
||||
| `gbuffers_*` for entities, hand, weather, sky, clouds | hard | each is its own Horizon pipeline today; each needs an Iris-shaped twin |
|
||||
| `shadow` | hard | a second world pass from the sun with its own culling (Horizon culls from the camera) and the pack's distortion in its vertex shader |
|
||||
| Custom textures, `noisetex` | easy | image loads |
|
||||
| Custom uniforms (`uniform.*` expressions) | medium | a small expression evaluator per frame |
|
||||
| Compute (`.csh`), `image2D` load/store | medium | wgpu has compute; storage formats are more limited than OpenGL's |
|
||||
| Distant Horizons, Voxy hooks, `DH_*` | not planned | Horizon's native LOD terrain shares world colour/depth; it does not expose these mods' APIs |
|
||||
|
||||
Order: preprocessor and legaliser until the three test packs parse; then `composite`/`final`/`deferred` with the
|
||||
uniforms; then `gbuffers_terrain`, `gbuffers_water` and `gbuffers_skybasic`/`skytextured` (the packs' look comes
|
||||
from these); then entities, hand, weather, clouds; then `shadow`; then custom textures, compute. Custom uniforms
|
||||
are done (below).
|
||||
|
||||
The test set is Complementary Reimagined, BSL and Sildur's Vibrant. Their licences don't allow redistribution, so
|
||||
they are downloaded for development only, outside the repository (never committed, never shipped, never uploaded);
|
||||
players bring their own, as with Iris.
|
||||
|
||||
## Spike results
|
||||
|
||||
Run on 2026-10-15 with naga 30.0.1 over Complementary Reimagined r5.9.3, BSL v10.1.8 and Sildur's Vibrant v2.02
|
||||
Extreme (the 26.3 builds from Modrinth, kept outside the repository). Every program of the overworld, Nether and End
|
||||
folders and the shared root was counted; the `dh_*`, `clrwl_*` and `voxy*` programs, which Iris only loads with
|
||||
Distant Horizons, Colorwheel or Voxy installed, were left out.
|
||||
|
||||
| Pack | Programs | naga as is | Preprocessed + legalised: parse | validate | WGSL written |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Complementary Reimagined | 183 | 0 | 180 | 180 | 180 |
|
||||
| BSL | 173 | 0 | 170 | 165 | 165 |
|
||||
| Sildur's Vibrant | 114 | 0 | 114 | 105 | 105 |
|
||||
| **Total** | **470** | **0** | **464** | **450** | **450 (96%)** |
|
||||
|
||||
"As is" is naga's GLSL front end on the files with `#include` inlined and Iris's macros defined: it accepts only
|
||||
`#version 440`–`460` with Vulkan-style bindings, and every pack is written for `#version 120`–`330` compatibility
|
||||
profiles. "Preprocessed + legalised" is the translation of steps 1–3 above, done by a throwaway token-level tool:
|
||||
Iris's `#include`, its macros (`MC_VERSION`, `MC_RENDER_STAGE_*`, ...) and the packs' `#if`s expanded with naga's own
|
||||
preprocessor (`pp-rs`), then:
|
||||
|
||||
- `#version 450 core`; `attribute`/`varying` to `in`/`out` with locations, one per declarator;
|
||||
- loose uniforms into one `std140` block (`bool`s stored as `int`);
|
||||
- `sampler2D` & co. split into texture + sampler bindings, uses rebuilt as `sampler2D(t, s)`, also through function
|
||||
parameters, and not where a local of the same name hides the uniform;
|
||||
- compatibility built-ins (`gl_Vertex`, `gl_MultiTexCoord*`, `gl_Color`, `gl_ModelViewMatrix`, `gl_Fog`, `ftransform()`,
|
||||
`gl_FragColor`, `gl_FragData[n]`) and Iris's core-profile names (`vaPosition`, `modelViewMatrix`, ...) declared;
|
||||
`texture2D`, `shadow2D`, `texture2DLod`, ... renamed;
|
||||
- global variables with non-constant initialisers (accepted by NVIDIA and AMD drivers, used throughout
|
||||
Complementary) declared at global scope and assigned at the start of `main`; unsized global arrays sized from
|
||||
their initialiser;
|
||||
- `texture(shadowSampler, ..).x`: the float widened to `vec4` first (naga has no scalar swizzle);
|
||||
- `isnan`, `isinf` and `modf` replaced by helper functions (WGSL has no `isnan`/`isinf`).
|
||||
|
||||
What is left, by cause:
|
||||
|
||||
| Programs | Cause | Fix |
|
||||
| ---: | --- | --- |
|
||||
| 9 | Sildur's passes a `mat3` between stages (`tbnMatrix`); WGSL can't | split matrix varyings into columns |
|
||||
| 6 | `writeonly image2D` without a format (`shadowcomp.csh` in Complementary and BSL) | take the format from the pack's image declaration (`shaders.properties` / `const int` format) |
|
||||
| 5 | naga GLSL front-end bug: a call result inside a constructor (`vec3(TAAJitter(..), z)`) leaves an unvisited expression (BSL) | spill call results to temporaries; report to naga |
|
||||
|
||||
The translator in `crates/horizon-shaderpack` now passes all of them that the packs enable by default (464 of 464,
|
||||
the WGSL read back and validated): matrix varyings are split into columns; the five BSL failures were not a naga bug
|
||||
but dead code after a `discard;` (how BSL turns its clouds and weather off), which the legaliser drops; the six
|
||||
`shadowcomp.csh` are off by default (`program.*/shadowcomp.enabled` in `shaders.properties`) and use images and arrays
|
||||
that only exist with their options on, so they wait for `shaders.properties`.
|
||||
|
||||
With `shaders.properties` read (`program.*.enabled` evaluated like Iris, image formats taken from `image.*`), the
|
||||
programs the packs enable translate: 180 of 180 for Complementary, 114 of 114 for Sildur's and 154 of 154 for BSL with
|
||||
its profile (it turns `composite2`/`composite3` off). With their colored lighting on, BSL's `shadowcomp.csh` translates
|
||||
too, and so do all 183 of Complementary's on Linux once `IRIS_FEATURE_CUSTOM_IMAGES` is defined: without it, the pack
|
||||
declares no voxel images but its `shaders.properties` still turns `shadowcomp` on. Which `IRIS_FEATURE_*` Horizon
|
||||
defines is decided when the stage that backs them exists.
|
||||
|
||||
So the GLSL side is feasible: naga's front end plus a legaliser reaches 96% on the three packs, and every remaining
|
||||
failure has a mechanical fix. Valid WGSL isn't a working pack yet: the output still has to be bound to Horizon's
|
||||
pipeline (render targets, the uniforms' values, vertex attributes for `gbuffers_*`), which is the emulation work.
|
||||
The spike also showed what the real translator needs that a token rewriter does badly: scopes (a local named like a
|
||||
uniform), types (which `texture` call returns a float) and declarations split across `#if`s, so it should rewrite a
|
||||
GLSL syntax tree after preprocessing, not tokens.
|
||||
|
||||
### Running a Java pack's full-screen passes
|
||||
|
||||
A folder holding `shaders/`, or a `.zip`, in `shaderpacks/` shows in the Shader Packs screen next to Horizon packs.
|
||||
Picking one translates every planned pass of each dimension folder (`java_pack.rs`) and runs them each frame
|
||||
(`gfx/java_stages.rs`):
|
||||
|
||||
1. the targets the pack clears are cleared;
|
||||
2. `begin*` and `prepare*` run, then Horizon's world enters `colortex0` and `depthtex0..2` on the side the flips give;
|
||||
3. `deferred*` and `composite*` run in order, each reading the side its plan gives and writing the other;
|
||||
4. `final` draws into a target that replaces the world's picture, where Horizon packs' `composite` output goes: the
|
||||
hand, menus and HUD are drawn over it.
|
||||
|
||||
When the pack has a `gbuffers_terrain` (or `textured_lit`, `textured`, `basic`, the program Iris falls back to), the
|
||||
pack draws the terrain itself (`gfx/java_terrain.rs`): Horizon's world pass skips its own solid and translucent
|
||||
terrain, then
|
||||
|
||||
- at step 2, after Horizon's sky, entities and the rest enter `colortex0` and depth, the pack's terrain program draws
|
||||
every visible section into its `RENDERTARGETS` (the side the next pass reads), tested against that depth; the
|
||||
depth is then copied into `depthtex0` and `depthtex1`;
|
||||
- before the first `composite`, as Iris does, `gbuffers_water` (or the terrain program) draws the translucent
|
||||
layer with the pack's `blend.*` or vanilla's blending; the depth goes into `depthtex0` only.
|
||||
|
||||
The pack's vertex program reads Horizon's own terrain vertices (the arena pages, instanced or flat draws): a glue
|
||||
function written before `main` decodes them into Iris's attributes (`vaPosition`/`gl_Vertex` relative to the camera,
|
||||
`gl_Color` with vanilla's face shading undone, `gl_MultiTexCoord0/1`, the normal and `at_tangent` from the quad's
|
||||
four corners, `mc_midTexCoord`, `at_midBlock`), and `mc_Entity.x` is the block's id from the pack's
|
||||
`block.properties`, matched to the sprite's block by name. `gtexture`/`texture` is a 2D atlas built from Horizon's
|
||||
sprite arrays (animated sprites copied in each frame), `lightmap` is Horizon's lightmap with Iris's texture matrix,
|
||||
and fragments under alpha 0.1 are discarded (`alphaTestRef`). `RGBA16` targets are 16-bit unorm where the GPU can
|
||||
render them (Chocapic13' packs its G-buffer into them; half floats broke it).
|
||||
|
||||
Occlusion culling works as in Horizon's world pass where the GPU has it: the pack's terrain program draws the
|
||||
sections the last test saw, the boxes of the others are tested against that depth (in OpenGL's layout, in a pass of
|
||||
their own since the pack's targets vary; on Metal the test reads the depth in the shader, as Horizon's does), and the
|
||||
pack's program then draws the ones in sight, flat draws included.
|
||||
|
||||
When the pack has a `shadow` program (`shadow_cutout`, `shadow` or `shadow_solid` for the solid layer, which holds
|
||||
cutout blocks; `shadow_water` or `shadow` for the translucent one), it draws a shadow map after `begin*`, as Iris's
|
||||
`ShadowRenderer`:
|
||||
|
||||
- the light's matrices are Iris's `ShadowMatrices`: `shadowModelView` turns camera-relative positions toward the sun
|
||||
(the moon at night) with the pack's `sunPathRotation`, snapped to `shadowIntervalSize`; `shadowProjection` is
|
||||
orthographic, `shadowDistance` each way, between `shadowNearPlane` and `shadowFarPlane` (Iris's defaults when the
|
||||
pack sets none); the shadow programs get them as `gl_ModelViewMatrix` and `gl_ProjectionMatrix`;
|
||||
- the renderer draws every section inside that box whose shadow can reach the camera's view (the box swept away
|
||||
from the light against the view's planes, as Iris's advanced shadow culling), from its own list (cave culling is
|
||||
off as in Iris, and no face is culled), solid then translucent, instanced draws only;
|
||||
- `shadowtex0` holds all of it, `shadowtex1` the depth before translucent terrain; both are bound as depth textures
|
||||
for `sampler2DShadow` (linear comparison, Iris's hardware filtering) and as float copies for `sampler2D` reads;
|
||||
- `shadowcolor0..7` are `shadowMapResolution` square in their `shadowcolor<N>Format`, cleared each frame to
|
||||
`shadowcolor<N>ClearColor` (white by default) unless `shadowcolor<N>Clear` is false.
|
||||
|
||||
A shadow program that doesn't translate or that wgpu refuses leaves the pack without its shadow map, with a warning:
|
||||
the terrain programs still run and every `shadowtex` reads as far (nothing in shadow).
|
||||
|
||||
What makes Horizon look like OpenGL to the pack:
|
||||
|
||||
- **Layout.** The translator ends every vertex shader by flipping `gl_Position.y` and moving depth from `[-w, w]` to
|
||||
`[0, w]`, so the targets keep OpenGL's layout (row 0 at the bottom). The world is flipped going in, `final` coming out.
|
||||
- **Depth.** Horizon's depth is reversed (1 near, 0 sky); the world goes in as `1 - depth`, and the matrices the pack
|
||||
gets (`gbufferProjection` and its inverse, previous frame too) are OpenGL's, rebuilt from Horizon's.
|
||||
- **Formats.** Iris formats become renderable wgpu formats: 8-bit unorm ones and `RGB10_A2` stay, `*32F` stay (sampled
|
||||
unfiltered), integer formats become 32-bit, the rest (`R11F_G11F_B10F`, `*16F`, `*16`) 16-bit float, with the
|
||||
channel count of the name.
|
||||
- **Samplers.** Metal allows 16 samplers per stage and Complementary binds 23 textures, so textures share four
|
||||
samplers by kind (`iris_linear`, `iris_nearest` for depth and 32-bit or integer targets, `iris_repeat` for
|
||||
`noisetex`, `iris_compare` for shadow lookups) instead of one each.
|
||||
- **`isnan`/`isinf`** test the float's bits: Metal compiles with fast math, which folds `x != x` to false, and a NaN
|
||||
then stays forever in Complementary's TAA history (the picture turns black).
|
||||
- **Uniforms.** Matrices, camera, view size, time, weather, fog and sky colour, eye state come from the frame;
|
||||
`eyeBrightness` is the block and sky light (× 16) of the camera entity's eye block, and `eyeBrightnessSmooth` follows
|
||||
it with the pack's `eyeBrightnessHalflife` (Iris's `SmoothedFloat`); `sunPosition`, `moonPosition`,
|
||||
`shadowLightPosition`, `sunAngle` and `shadowAngle` are `CelestialUniforms`' (the sky's `sun_angle` and `moon_angle`
|
||||
turned by Y -90, the pack's `sunPathRotation` about Z, then the angle about X); `noisetex` is a fixed 256² noise.
|
||||
- **Custom uniforms.** `uniform.<type>.<name>` and `variable.<type>.<name>` in `shaders.properties` are evaluated
|
||||
each frame (`horizon-shaderpack`'s `uniforms.rs`: Iris's operators and priorities, its functions, `smooth` with
|
||||
`SmoothFloat`'s half-lives). A definition naming something Horizon doesn't provide (`biome`, `blindness`...) is left
|
||||
out with everything that uses it, as Iris leaves out the ones it can't resolve: the uniform stays 0.
|
||||
- **The vanilla sky.** While the pack runs, `sunPathRotation` tilts Horizon's sun, moon and stars as Iris tilts
|
||||
vanilla's, `clouds = off | fast | fancy` replaces the cloud option, and `sun = false` / `moon = false` hide those.
|
||||
Complementary and Sildur's turn the vanilla clouds off: drawn anyway, they wrote depth the packs' light shafts and
|
||||
fog took for terrain.
|
||||
|
||||
Measured on the Mac mini (M4 Pro) with the packs' default options: BSL v10.1.8 and Complementary Reimagined r5.9.3
|
||||
grade and light Horizon's world as their post-processing would. Sildur's Vibrant v2.02 draws only its sky: its
|
||||
`deferred` reads the albedo `gbuffers_*` write to `colortex4` (not checked again since the terrain programs run).
|
||||
Without the other `gbuffers_*`, no pack has its own sky yet, and only terrain casts shadows; Distant Horizons' and
|
||||
Voxy's depth read as far. Horizon's world goes into the target the pack's `gbuffers_terrain` (or the program Iris falls
|
||||
back to) draws to first: `colortex0` for most packs, `colortex1` for MakeUp. Not supported yet: images and compute
|
||||
(`.csh`; a pack that needs them doesn't load), mipmapped targets (`colortexNMipmapEnabled` is read but ignored), `.dat`
|
||||
and resource-location custom textures (PNG files in the pack only), `shadowcomp` passes, `shadowMapFov` (a perspective
|
||||
shadow map), `shadow.culling` and `shadowDistanceRenderMul` (the box above is the only bound). With the pack's terrain:
|
||||
entities, the sky, the hand, weather and clouds are still Horizon's (in that first target only), block ids come from
|
||||
sprite names rather than block states, distant (LOD) terrain doesn't repeat its textures, and only the overworld has
|
||||
been checked.
|
||||
|
||||
### Compatibility
|
||||
|
||||
Tested on 2026-10-22 on the Mac mini (M4 Pro, Metal) with each pack's default options, the 26.3 builds from Modrinth or
|
||||
the authors' pages (dev-only test inputs, kept outside the repository). "Loads" means every pass the pack enables for
|
||||
the overworld, Nether and End is preprocessed, translated and validated, as the game does when the pack is picked
|
||||
(`java_pack::tests::real_packs_load` with `HORIZON_TEST_SHADERPACKS`); "drawn" means it was also picked in the game
|
||||
and screenshotted. Full-screen passes, `gbuffers_terrain`, `gbuffers_water` and the terrain's shadow map run
|
||||
(above); no pack has its own entities or sky yet.
|
||||
|
||||
| Pack | Version | Result | First thing that stops it |
|
||||
| --- | --- | --- | --- |
|
||||
| BSL | v10.1.8 | drawn: 26 passes and its terrain and water programs | |
|
||||
| Complementary Reimagined | r5.9.3 | drawn: 27 passes | |
|
||||
| Complementary Unbound | r5.8 | loads: 27 passes | |
|
||||
| Sildur's Vibrant Extreme | 2.02 | drawn: 18 passes, only its sky (above) | |
|
||||
| Steadfast | 0.8.0 | drawn: 3 passes | |
|
||||
| Chocapic13' | V9 High | drawn: 16 passes and its terrain and water programs | |
|
||||
| MakeUp Ultra Fast | 9.6 | drawn: 6 passes and its terrain and water programs | |
|
||||
| Photon | v1.4 | loads: 27 passes and its terrain and water programs; draws black | its sky and light come from a compute pass and `.dat` look-up tables, not loaded yet |
|
||||
| Bliss | 2.1.2 | fails | `world0/composite1.fsh`: a vector component passed to an `inout` parameter (WGSL can't point at one) |
|
||||
| Bloop | 1.8.0-Alpha-3 | fails | `world0/prepare1.fsh`: `mat2(vec4)` and other constructors naga's GLSL front end refuses |
|
||||
| Noble | v1.9.9 | fails (Apple and NVIDIA) | `world0/deferred4.vsh`: a `vec3[9][9]` varying (nested arrays between stages) |
|
||||
| Rethinking Voxels | r0.1-beta9 | fails | `world0/prepare.fsh`: `1 << 31` overflows naga's constant folding; it also needs images |
|
||||
|
||||
What the translator does for these packs, as Iris (JCPP and its GLSL transformer) does:
|
||||
|
||||
- **`#if` reads floats as JCPP does**: a float in a condition, written there or through a macro, counts as its
|
||||
integer part, and an empty operand as 0. The same holds in `shaders.properties`, where Steadfast's
|
||||
`#elif ATMOSPHERE_FOG_STRENGTH_NOON == 0.5` stopped the pack (`UnexpectedToken(Float(0.5))`).
|
||||
- **A later `#define` replaces an earlier one** instead of failing (Bloop's `DefineRedefined`), and an unterminated
|
||||
`#if` runs to the end of the file.
|
||||
- **`shaders.properties`** keeps its empty lines and backslashes through the preprocessor, as Iris's does, so a
|
||||
continued line doesn't swallow the next one.
|
||||
- **16-bit and 8-bit types** (`float16_t`, `u16vec2`, `packFloat2x16`...), which Noble uses when the GPU reports
|
||||
NVIDIA (`NV_gpu_shader5`), become their 32-bit types: WGSL has none.
|
||||
- **What naga refuses**: `%` with a scalar and a vector operand goes through overloads, a `const` naga can't fold
|
||||
(`mix`, `?:`, `smoothstep`) becomes a global set at the start of `main`, `sqrt` of a negative constant is
|
||||
evaluated at run time, and a fragment output narrower than four channels (`out vec3`, Steadfast's `final`) is
|
||||
written through a `vec4` with alpha 1 as OpenGL fills it.
|
||||
- **What GL takes and naga doesn't** (Photon, Chocapic13', MakeUp Ultra Fast, Bliss): `texture2D` and its kin without a
|
||||
level outside fragment shaders sample level 0 (`textureLod`); a struct passed between stages travels as one varying
|
||||
per field (matrices by column, arrays by element); `textureGather` reads the four texels with `texelFetch`; a
|
||||
`bool` given to `vec3(..)` and other numeric constructors becomes `cond ? 1.0 : 0.0`; `const in` parameters lose
|
||||
the `const`; an array field of a struct that never reaches a buffer gets its natural stride (naga lays every struct
|
||||
out as std140, so `Spline(.., float[6](..))` didn't match); `modf` into a vector component assigns it instead of
|
||||
passing a pointer.
|
||||
- **Every translated program parses again as WGSL** in `real_packs_load`: wgpu is given the text, and naga's WGSL
|
||||
writer can emit what its WGSL parser refuses (`&v.y`).
|
||||
|
||||
### Order to implement
|
||||
|
||||
1. **Translator crate** (`horizon-shaderpack`): the preprocessor (includes, option `#define`s with the player's
|
||||
values, Iris macros) and a syntax-tree legaliser covering every rewrite above plus the three fixes in the table;
|
||||
a test running it over the user's own copies of the three packs when they're present (never committed), and
|
||||
small hand-written GLSL fixtures in the repo.
|
||||
2. **`shaders.properties`** and option screens: the option list, sliders, profiles, `program.*.enabled`, blend modes.
|
||||
3. **Full-screen programs**: `deferred*`, `composite*`, `final` with `colortex0..15` (formats from the pack),
|
||||
`depthtex0..2`, `noisetex` and the uniforms (most are already computed for the world shaders). This alone runs
|
||||
Sildur's and BSL post-processing over Horizon's own world.
|
||||
4. **`gbuffers_terrain`, `gbuffers_water`** (done, above), **`gbuffers_sky*`**: Horizon's terrain vertices decoded
|
||||
into Iris's attributes (`mc_Entity` from `block.properties`, `mc_midTexCoord`, `at_tangent`, lightmap), a second
|
||||
set of terrain pipelines writing the pack's render targets.
|
||||
5. **Entities, block entities, hand, weather, clouds, particles** `gbuffers_*`.
|
||||
6. **Shadow pass** (terrain done, above): entities and block entities from the sun into `shadowtex0/1` and
|
||||
`shadowcolor0/1`, culled from the light's view, with the pack's distortion; `shadowcomp`.
|
||||
7. **The rest**: custom textures, compute programs and images, per-dimension
|
||||
folders, `entity.properties`/`item.properties`.
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Compare the Vulkan renderer on your Mac
|
||||
|
||||
The `vulkan` profile asks Minecraft 26.3 to use its existing Vulkan backend, with
|
||||
its original OpenGL fallback. It uses balanced mods/distances and omits Iris.
|
||||
its original OpenGL fallback. It uses balanced mods/distances.
|
||||
The game renderer handles the backend; this project has not written a separate
|
||||
Metal renderer. The benchmark records the actual backend and driver.
|
||||
|
||||
@@ -23,7 +23,7 @@ Use the viewer's actual backend field: OpenGL fallback is an OpenGL result.
|
||||
After the isolated test works, `foreground-first` and `foreground-vulkan` compare
|
||||
the two backends with the same combined terrain controls. Their 120 FPS adaptive
|
||||
goal controls background duty cycle; rendering remains uncapped in pack options.
|
||||
Do not add Iris or shaders to one side of an isolated backend comparison.
|
||||
Do not add shaders to one side of an isolated backend comparison.
|
||||
|
||||
Linux software rendering checks reached the actual Vulkan backend, rendered the
|
||||
demo world and recorded matching CSV/JSON frame/tick samples. An unavailable
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Horizon contributors
|
||||
|
||||
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.
|
||||
@@ -1,35 +0,0 @@
|
||||
# The sample Horizon shader pack (docs/shader-packs.md). Copy this folder into
|
||||
# `shaderpacks/` beside options.txt and pick it in Video Settings > Shader Packs.
|
||||
[pack]
|
||||
name = "Horizon Sample"
|
||||
description = "A warm grade, sun-lit haze and a soft vignette."
|
||||
authors = ["Horizon contributors"]
|
||||
version = "1.0.0"
|
||||
license = "MIT"
|
||||
api = 1
|
||||
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/golden_hour.wgsl"
|
||||
|
||||
[options.saturation]
|
||||
label = "Saturation"
|
||||
default = 1.15
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[options.warmth]
|
||||
label = "Warmth"
|
||||
default = 0.35
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[options.haze]
|
||||
label = "Sun haze"
|
||||
default = true
|
||||
|
||||
[options.vignette]
|
||||
label = "Vignette"
|
||||
default = true
|
||||
@@ -1,44 +0,0 @@
|
||||
// Golden hour: a warm grade, haze that glows towards the sun with distance,
|
||||
// greyer when it rains, and a soft vignette. The prelude (bindings, `frame`,
|
||||
// `StageIn`, depth helpers) comes first; see docs/shader-packs.md.
|
||||
override saturation: f32 = 1.15;
|
||||
override warmth: f32 = 0.35;
|
||||
override haze: bool = true;
|
||||
override vignette: bool = true;
|
||||
|
||||
@fragment fn fs_main(in: StageIn) -> @location(0) vec4<f32> {
|
||||
var c = textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb;
|
||||
let depth = depth_at(in.uv);
|
||||
let rain = max(frame.weather.x, frame.weather.y);
|
||||
|
||||
// Saturation, less of it in rain.
|
||||
let grey = vec3(luminance(c));
|
||||
c = mix(grey, c, saturation * (1.0 - 0.5 * rain));
|
||||
|
||||
// Warm the highlights more than the shadows.
|
||||
let warm = vec3(1.0 + 0.12 * warmth, 1.0 + 0.03 * warmth, 1.0 - 0.10 * warmth);
|
||||
c = mix(c, c * warm, smoothstep(0.1, 0.8, luminance(c)));
|
||||
|
||||
if haze && frame.camera_state.x == 0.0 {
|
||||
// How much the view ray faces the sun, and how far it goes.
|
||||
let direction = normalize(world_offset(in.uv, max(depth, 1e-6)));
|
||||
let facing = max(dot(direction, frame.sun.xyz), 0.0);
|
||||
let low_sun = 1.0 - abs(frame.sun.y);
|
||||
let glow = pow(facing, 6.0) * low_sun * (1.0 - rain);
|
||||
var amount = 0.0;
|
||||
if is_sky(depth) {
|
||||
amount = glow * 0.35;
|
||||
} else {
|
||||
let distance = linear_depth(depth) / max(frame.view_params.z, 16.0);
|
||||
amount = smoothstep(0.15, 1.0, distance) * (0.15 + 0.45 * glow);
|
||||
}
|
||||
let tint = mix(frame.fog_color.rgb, vec3(1.0, 0.72, 0.42), glow);
|
||||
c = mix(c, tint, clamp(amount, 0.0, 1.0));
|
||||
}
|
||||
|
||||
if vignette {
|
||||
let d = length((in.uv - 0.5) * vec2(frame.resolution.x * frame.resolution.w, 1.0));
|
||||
c *= 1.0 - 0.3 * smoothstep(0.45, 0.95, d);
|
||||
}
|
||||
return vec4(clamp(c, vec3(0.0), vec3(1.0)), 1.0);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Horizon contributors
|
||||
|
||||
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.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Horizon Ultra: multi-pass API 2 WGSL shader pack with PCF directional shadows,
|
||||
# screen-space AO, water SSR & absorption, screen-space godrays, HDR bloom and ACES tonemapping.
|
||||
[pack]
|
||||
name = "Horizon Ultra"
|
||||
description = "PCF sun/moon shadows, ambient occlusion, water reflections, godrays, bloom and ACES filmic tonemapping."
|
||||
authors = ["Horizon contributors"]
|
||||
version = "2.0.0"
|
||||
license = "MIT"
|
||||
api = 2
|
||||
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/deferred_lighting.wgsl"
|
||||
format = "rgba16float"
|
||||
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/water_reflections.wgsl"
|
||||
format = "rgba16float"
|
||||
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/bloom_godrays.wgsl"
|
||||
scale = 0.5
|
||||
format = "rgba16float"
|
||||
|
||||
[[stage]]
|
||||
kind = "composite"
|
||||
shader = "shaders/final_tonemap.wgsl"
|
||||
|
||||
[profiles.low]
|
||||
shadows = false
|
||||
ao_strength = 0.25
|
||||
water_ssr = false
|
||||
godrays = false
|
||||
bloom_strength = 0.15
|
||||
tonemap_aces = true
|
||||
vignette = false
|
||||
|
||||
[profiles.medium]
|
||||
shadows = true
|
||||
shadow_strength = 0.70
|
||||
ao_strength = 0.50
|
||||
water_ssr = true
|
||||
godrays = true
|
||||
bloom_strength = 0.25
|
||||
tonemap_aces = true
|
||||
vignette = true
|
||||
|
||||
[profiles.high]
|
||||
shadows = true
|
||||
shadow_strength = 0.85
|
||||
ao_strength = 0.75
|
||||
water_ssr = true
|
||||
godrays = true
|
||||
bloom_strength = 0.35
|
||||
tonemap_aces = true
|
||||
vignette = true
|
||||
|
||||
[profiles.ultra]
|
||||
shadows = true
|
||||
shadow_strength = 1.0
|
||||
ao_strength = 1.0
|
||||
water_ssr = true
|
||||
godrays = true
|
||||
bloom_strength = 0.45
|
||||
tonemap_aces = true
|
||||
vignette = true
|
||||
|
||||
[options.shadows]
|
||||
label = "Directional Shadows"
|
||||
description = "Cast real-time sun and moon PCF shadows across terrain and entities."
|
||||
default = true
|
||||
|
||||
[options.shadow_strength]
|
||||
label = "Shadow Strength"
|
||||
description = "Intensity of directional sun and moon shadows."
|
||||
default = 0.85
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[options.ao_strength]
|
||||
label = "Ambient Occlusion"
|
||||
description = "Screen-space ambient occlusion in corners and crevices."
|
||||
default = 0.75
|
||||
min = 0.0
|
||||
max = 1.5
|
||||
step = 0.05
|
||||
|
||||
[options.water_ssr]
|
||||
label = "Water Reflections & Refraction"
|
||||
description = "Screen-space sky and terrain reflections on water and translucent surfaces."
|
||||
default = true
|
||||
|
||||
[options.godrays]
|
||||
label = "Godrays"
|
||||
description = "Atmospheric light shafts cast through trees and terrain towards the sun."
|
||||
default = true
|
||||
|
||||
[options.bloom_strength]
|
||||
label = "HDR Bloom"
|
||||
description = "Soft glow around bright sky, lava, torches and emissive blocks."
|
||||
default = 0.35
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[options.exposure]
|
||||
label = "Exposure"
|
||||
description = "Overall scene exposure multiplier before filmic tonemapping."
|
||||
default = 1.10
|
||||
min = 0.5
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[options.saturation]
|
||||
label = "Color Saturation"
|
||||
description = "Color vibrancy of the final tonemapped image."
|
||||
default = 1.12
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[options.tonemap_aces]
|
||||
label = "ACES Filmic Tonemap"
|
||||
description = "Apply ACES fitted filmic curve to preserve highlight detail."
|
||||
default = true
|
||||
|
||||
[options.vignette]
|
||||
label = "Cinematic Vignette"
|
||||
description = "Subtle darkening at the edges of the viewport."
|
||||
default = true
|
||||
@@ -1,61 +0,0 @@
|
||||
// Stage 3 (Composite, half-resolution): HDR bloom extraction + 9-tap tent filter
|
||||
// and radial light shafts (screen-space godrays) towards the sun/moon.
|
||||
override godrays: bool = true;
|
||||
override bloom_strength: f32 = 0.35;
|
||||
|
||||
@fragment fn fs_main(in: StageIn) -> @location(0) vec4<f32> {
|
||||
let base = textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb;
|
||||
let texel = frame.resolution.zw * 2.0;
|
||||
|
||||
// 9-tap soft bloom filter around bright highlights.
|
||||
var bloom = vec3(0.0);
|
||||
if bloom_strength > 0.001 {
|
||||
let offsets = array<vec2<f32>, 9>(
|
||||
vec2(0.0, 0.0),
|
||||
vec2(1.0, 0.0),
|
||||
vec2(-1.0, 0.0),
|
||||
vec2(0.0, 1.0),
|
||||
vec2(0.0, -1.0),
|
||||
vec2(1.0, 1.0),
|
||||
vec2(-1.0, 1.0),
|
||||
vec2(1.0, -1.0),
|
||||
vec2(-1.0, -1.0),
|
||||
);
|
||||
let weights = array<f32, 9>(0.25, 0.125, 0.125, 0.125, 0.125, 0.0625, 0.0625, 0.0625, 0.0625);
|
||||
for (var i = 0u; i < 9u; i++) {
|
||||
let sample_c = textureSampleLevel(previous, linear_sampler, in.uv + offsets[i] * texel, 0.0).rgb;
|
||||
let lum = luminance(sample_c);
|
||||
let bright = max(lum - 0.72, 0.0) / max(lum, 1e-4);
|
||||
bloom += sample_c * bright * weights[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Radial godrays from the sun's screen projection.
|
||||
var shafts = vec3(0.0);
|
||||
if godrays && frame.camera_state.x == 0.0 && frame.dimension.y > 0.5 {
|
||||
let sun_clip = frame.projection * (frame.view * vec4(frame.sun.xyz * 128.0, 1.0));
|
||||
if sun_clip.w > 0.0 {
|
||||
let sun_ndc = sun_clip.xy / sun_clip.w;
|
||||
let sun_uv = vec2(sun_ndc.x * 0.5 + 0.5, 0.5 - sun_ndc.y * 0.5);
|
||||
let delta = (sun_uv - in.uv) / 12.0;
|
||||
var uv_Cursor = in.uv;
|
||||
var transmittance = 0.0;
|
||||
var decay = 1.0;
|
||||
for (var s = 0u; s < 12u; s++) {
|
||||
uv_Cursor += delta;
|
||||
let sample_uv = clamp(uv_Cursor, vec2(0.001), vec2(0.999));
|
||||
if is_sky(depth_at(sample_uv)) {
|
||||
transmittance += decay;
|
||||
}
|
||||
decay *= 0.92;
|
||||
}
|
||||
let ray = normalize(world_offset(in.uv, 1e-5));
|
||||
let sun_facing = pow(max(dot(ray, frame.sun.xyz), 0.0), 4.0);
|
||||
let sunset = smoothstep(-0.1, 0.25, frame.sun.y);
|
||||
let shaft_color = mix(vec3(1.0, 0.55, 0.25), vec3(1.0, 0.92, 0.75), sunset);
|
||||
shafts = shaft_color * (transmittance / 12.0) * sun_facing * 0.28 * (1.0 - frame.weather.x);
|
||||
}
|
||||
}
|
||||
|
||||
return vec4(base + bloom * bloom_strength + shafts, 1.0);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Stage 1: G-buffer normals, 8-tap screen-space ambient
|
||||
// occlusion, directional sun/moon PCF shadow mapping, and rain puddle wetness.
|
||||
override shadows: bool = true;
|
||||
override shadow_strength: f32 = 0.85;
|
||||
override ao_strength: f32 = 0.75;
|
||||
|
||||
fn compute_ao(uv: vec2<f32>, depth: f32, view_pos: vec3<f32>) -> f32 {
|
||||
if ao_strength <= 0.001 {
|
||||
return 1.0;
|
||||
}
|
||||
let step_uv = frame.resolution.zw * 2.5;
|
||||
let offsets = array<vec2<f32>, 8>(
|
||||
vec2(1.0, 0.0),
|
||||
vec2(-1.0, 0.0),
|
||||
vec2(0.0, 1.0),
|
||||
vec2(0.0, -1.0),
|
||||
vec2(0.707, 0.707),
|
||||
vec2(-0.707, 0.707),
|
||||
vec2(0.707, -0.707),
|
||||
vec2(-0.707, -0.707),
|
||||
);
|
||||
var occlusion = 0.0;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let sample_uv = clamp(uv + offsets[i] * step_uv, vec2(0.001), vec2(0.999));
|
||||
let sd = opaque_depth_at(sample_uv);
|
||||
if !is_sky(sd) {
|
||||
let sp = view_position(sample_uv, sd);
|
||||
let diff = sp - view_pos;
|
||||
let dist = length(diff);
|
||||
if dist > 0.02 && dist < 3.5 {
|
||||
let horizon = clamp(diff.z / dist, 0.0, 1.0);
|
||||
occlusion += horizon * smoothstep(3.5, 0.2, dist);
|
||||
}
|
||||
}
|
||||
}
|
||||
return clamp(1.0 - (occlusion * 0.18 * ao_strength), 0.15, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_main(in: StageIn) -> @location(0) vec4<f32> {
|
||||
let base_srgb = textureSampleLevel(scene_color, linear_sampler, in.uv, 0.0).rgb;
|
||||
var color = srgb_to_linear(base_srgb);
|
||||
let depth = opaque_depth_at(in.uv);
|
||||
let light_dir = normalize(select(frame.moon.xyz, frame.sun.xyz, frame.sun.y >= -0.05));
|
||||
let sun_elevation = clamp(frame.sun.y, -1.0, 1.0);
|
||||
let day_factor = smoothstep(-0.08, 0.18, sun_elevation);
|
||||
let sunset_factor = smoothstep(-0.12, 0.05, sun_elevation) * (1.0 - smoothstep(0.05, 0.35, sun_elevation));
|
||||
|
||||
if is_sky(depth) {
|
||||
let ray = normalize(world_offset(in.uv, 1e-5));
|
||||
let sun_dot = max(dot(ray, frame.sun.xyz), 0.0);
|
||||
let mie = pow(sun_dot, 12.0) * (0.35 + 0.65 * sunset_factor) * (1.0 - frame.weather.x * 0.8);
|
||||
let sun_tint = mix(vec3(1.0, 0.88, 0.68), vec3(1.0, 0.45, 0.18), sunset_factor);
|
||||
color += sun_tint * mie * 0.85;
|
||||
return vec4(color, 1.0);
|
||||
}
|
||||
|
||||
let world_rel = world_offset(in.uv, depth);
|
||||
let view_pos = view_position(in.uv, depth);
|
||||
let normal = world_normal_at(in.uv);
|
||||
let mat = material_at(in.uv);
|
||||
|
||||
// Ambient occlusion from depth discontinuity neighborhood.
|
||||
let ao = compute_ao(in.uv, depth, view_pos);
|
||||
|
||||
// Directional sun/moon shadow factor.
|
||||
var shadow = 1.0;
|
||||
if shadows && frame.dimension.y > 0.5 {
|
||||
let raw_shadow = sample_shadow(world_rel, normal);
|
||||
shadow = mix(1.0, raw_shadow, shadow_strength);
|
||||
}
|
||||
|
||||
let ndotl = max(dot(normal, light_dir), 0.0);
|
||||
let sun_color = mix(
|
||||
vec3(0.22, 0.28, 0.48),
|
||||
mix(vec3(1.05, 0.96, 0.86), vec3(1.18, 0.58, 0.28), sunset_factor),
|
||||
day_factor,
|
||||
);
|
||||
let sky_ambient = mix(vec3(0.08, 0.11, 0.20), srgb_to_linear(frame.sky_color.rgb) * 0.55, day_factor);
|
||||
let direct_light = sun_color * ndotl * shadow * (1.0 - frame.weather.x * 0.55);
|
||||
let bounce_light = sky_ambient * (0.45 + 0.55 * max(normal.y, 0.0)) * ao;
|
||||
|
||||
// Wetness specular sheen during rain on upward-facing surfaces.
|
||||
let wetness = frame_ext.environment.z;
|
||||
let view_dir = normalize(-world_rel);
|
||||
let half_vec = normalize(light_dir + view_dir);
|
||||
let wet_mask = wetness * smoothstep(0.65, 0.95, normal.y) * mat.g;
|
||||
let spec = pow(max(dot(normal, half_vec), 0.0), 48.0) * wet_mask * shadow * 0.65;
|
||||
|
||||
let shade_boost = mix(0.68 * ao, 1.0 + 0.35 * ndotl * shadow, 0.65);
|
||||
color = color * shade_boost + color * direct_light * 0.32 + bounce_light * 0.08 + sun_color * spec;
|
||||
return vec4(max(color, vec3(0.0)), 1.0);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Stage 4 (Final): Combines full-resolution scene with bloom/godrays, applies
|
||||
// exposure, ACES filmic tonemapping, saturation, and cinematic vignette.
|
||||
override exposure: f32 = 1.10;
|
||||
override saturation: f32 = 1.12;
|
||||
override tonemap_aces: bool = true;
|
||||
override vignette: bool = true;
|
||||
|
||||
@fragment fn fs_main(in: StageIn) -> @location(0) vec4<f32> {
|
||||
var hdr = textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb * exposure;
|
||||
|
||||
if tonemap_aces {
|
||||
hdr = aces_tonemap(hdr);
|
||||
} else {
|
||||
hdr = clamp(hdr, vec3(0.0), vec3(1.0));
|
||||
}
|
||||
|
||||
var srgb = linear_to_srgb(hdr);
|
||||
let luma = luminance(srgb);
|
||||
let rain_desat = 1.0 - 0.25 * max(frame.weather.x, frame.weather.y);
|
||||
srgb = mix(vec3(luma), srgb, saturation * rain_desat);
|
||||
|
||||
if vignette {
|
||||
let aspect = frame.resolution.x * frame.resolution.w;
|
||||
let d = length((in.uv - 0.5) * vec2(aspect, 1.0));
|
||||
srgb *= 1.0 - 0.26 * smoothstep(0.48, 0.98, d);
|
||||
}
|
||||
|
||||
return vec4(clamp(srgb, vec3(0.0), vec3(1.0)), 1.0);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Stage 2 (Composite): Screen-space water reflections (SSR), Fresnel Schlick,
|
||||
// and Beer-Lambert underwater depth absorption using scene_depth vs opaque_depth.
|
||||
override water_ssr: bool = true;
|
||||
|
||||
@fragment fn fs_main(in: StageIn) -> @location(0) vec4<f32> {
|
||||
var color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0).rgb;
|
||||
let surface_depth = depth_at(in.uv);
|
||||
let floor_depth = opaque_depth_at(in.uv);
|
||||
let mat = material_at(in.uv);
|
||||
|
||||
// Translucent water/glass layer exists where surface_depth is closer than floor_depth
|
||||
// (reversed depth: closer = larger value) or where the G-buffer water mask is set.
|
||||
let is_translucent = (surface_depth > floor_depth + 1e-5) || (mat.b > 0.5);
|
||||
if !water_ssr || is_sky(surface_depth) || !is_translucent {
|
||||
return vec4(color, 1.0);
|
||||
}
|
||||
|
||||
let water_pos = world_offset(in.uv, surface_depth);
|
||||
let floor_pos = world_offset(in.uv, max(floor_depth, 1e-6));
|
||||
let water_column = clamp(length(floor_pos - water_pos), 0.0, 32.0);
|
||||
|
||||
// Beer-Lambert depth absorption in deep water.
|
||||
let absorption = exp(-vec3(0.22, 0.07, 0.03) * water_column * 0.35);
|
||||
color *= mix(vec3(1.0), absorption, clamp(water_column * 0.08, 0.0, 0.65));
|
||||
|
||||
// Wave normal perturbation and Fresnel reflection.
|
||||
let world_xz = (water_pos.xz + frame.camera_position.xz) * 0.08;
|
||||
let wave_uv = world_xz + vec2(frame.time.x * 0.015, -frame.time.x * 0.011);
|
||||
let noise_sample = textureSampleLevel(noise_texture, repeat_sampler, wave_uv, 0.0).rgb * 2.0 - 1.0;
|
||||
let normal = normalize(vec3(noise_sample.x * 0.06, 1.0, noise_sample.y * 0.06));
|
||||
let view_dir = normalize(-water_pos);
|
||||
let refl_dir = reflect(-view_dir, normal);
|
||||
let fresnel = clamp(0.04 + 0.96 * pow(1.0 - max(dot(view_dir, normal), 0.0), 4.0), 0.0, 0.85);
|
||||
|
||||
// Ray-march 8 steps in screen space along the reflection ray.
|
||||
var refl_color = srgb_to_linear(frame.sky_color.rgb);
|
||||
let sun_spec = pow(max(dot(refl_dir, frame.sun.xyz), 0.0), 96.0) * 2.5 * (1.0 - frame.weather.x);
|
||||
refl_color += vec3(1.0, 0.9, 0.7) * sun_spec;
|
||||
|
||||
for (var step = 1u; step <= 8u; step++) {
|
||||
let march_pos = water_pos + refl_dir * (f32(step) * 2.2);
|
||||
let clip = frame.projection * (frame.view * vec4(march_pos, 1.0));
|
||||
if clip.w > 0.0 {
|
||||
let ndc = clip.xyz / clip.w;
|
||||
let sample_uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
||||
if all(sample_uv >= vec2(0.01)) && all(sample_uv <= vec2(0.99)) {
|
||||
let scene_d = opaque_depth_at(sample_uv);
|
||||
if !is_sky(scene_d) && ndc.z <= scene_d && (scene_d - ndc.z) < 0.08 {
|
||||
refl_color = textureSampleLevel(previous, linear_sampler, sample_uv, 0.0).rgb;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
color = mix(color, refl_color, fresnel * 0.55);
|
||||
return vec4(color, 1.0);
|
||||
}
|
||||
+2
-2
@@ -25,9 +25,9 @@ The opening, then Play ([clip](docs/opening.webm)):
|
||||
or another installed Horizon version, so the original Minecraft install is
|
||||
only required when no valid imported copy exists.
|
||||
- **Mods**: search Modrinth (CurseForge support is coming soon) for resource
|
||||
packs, Iris/OptiFine shader packs and modpacks, each marked Works, Partly or Doesn't run in
|
||||
packs and modpacks, each marked Works, Partly or Doesn't run in
|
||||
Horizon; Java mod jars are never offered. Install puts a pack and what it needs in
|
||||
`resourcepacks/` or `shaderpacks/` only after its SHA-512 matches, and a
|
||||
`resourcepacks/` only after its SHA-512 matches, and a
|
||||
modpack goes to the Modpacks page; installed packs show their source and checksum, with Update
|
||||
and Remove ([docs/mod-browser.md](../docs/mod-browser.md)).
|
||||
- **Modpacks**: import a Java modpack (a Modrinth `.mrpack`, a CurseForge zip, or a Prism,
|
||||
|
||||
+7
-33
@@ -3,9 +3,8 @@
|
||||
//! matches (docs/mod-browser.md).
|
||||
//!
|
||||
//! Offered: Java mods (Fabric, NeoForge, Forge), whose content Horizon ports and whose jar is
|
||||
//! checked before install ([`java`]), modpacks (imported and ported), resource packs
|
||||
//! (`resourcepacks/`) and Java shader packs (`shaderpacks/`, Iris and OptiFine). Java code itself
|
||||
//! doesn't run in Horizon (docs/mods.md).
|
||||
//! checked before install ([`java`]), modpacks (imported and ported) and resource packs
|
||||
//! (`resourcepacks/`). Java code itself doesn't run in Horizon (docs/mods.md).
|
||||
pub mod curseforge;
|
||||
pub mod details;
|
||||
pub mod download;
|
||||
@@ -25,25 +24,18 @@ pub const GAME: &str = "26.3";
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Kind {
|
||||
ResourcePack,
|
||||
ShaderPack,
|
||||
Modpack,
|
||||
/// A Java mod: its content is ported ([`java`]).
|
||||
Mod,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub const ALL: [Kind; 4] = [
|
||||
Kind::Mod,
|
||||
Kind::Modpack,
|
||||
Kind::ResourcePack,
|
||||
Kind::ShaderPack,
|
||||
];
|
||||
pub const ALL: [Kind; 3] = [Kind::Mod, Kind::Modpack, Kind::ResourcePack];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Mod => "Mods",
|
||||
Kind::ResourcePack => "Resource packs",
|
||||
Kind::ShaderPack => "Shader packs",
|
||||
Kind::Modpack => "Modpacks",
|
||||
}
|
||||
}
|
||||
@@ -53,7 +45,6 @@ impl Kind {
|
||||
pub fn folder(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::ResourcePack => Some("resourcepacks"),
|
||||
Kind::ShaderPack => Some("shaderpacks"),
|
||||
Kind::Modpack | Kind::Mod => None,
|
||||
}
|
||||
}
|
||||
@@ -123,7 +114,7 @@ pub struct Project {
|
||||
pub icon: Option<String>,
|
||||
pub license: String,
|
||||
pub kind: Kind,
|
||||
/// The Minecraft versions its files list, and the loaders (`iris`, `optifine`, `minecraft`...).
|
||||
/// The Minecraft versions its files list, and the loaders (`fabric`, `neoforge`, `minecraft`...).
|
||||
pub game_versions: Vec<String>,
|
||||
pub loaders: Vec<String>,
|
||||
}
|
||||
@@ -186,8 +177,8 @@ impl Version {
|
||||
pub struct Query {
|
||||
pub text: String,
|
||||
pub kind: Kind,
|
||||
/// Off: only what runs (made for [`GAME`], Iris or OptiFine shaders). On: everything of the
|
||||
/// kind, each result marked with how well it runs.
|
||||
/// Off: only what runs (made for [`GAME`]). On: everything of the kind, each result marked
|
||||
/// with how well it runs.
|
||||
pub everything: bool,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
@@ -250,9 +241,6 @@ pub fn source(id: SourceId) -> Option<Box<dyn Source>> {
|
||||
sources().into_iter().find(|s| s.id() == id)
|
||||
}
|
||||
|
||||
/// The loaders a Java shader pack may name that Horizon translates.
|
||||
const SHADER_LOADERS: [&str; 2] = ["iris", "optifine"];
|
||||
|
||||
/// How well a project of `kind` made for `game_versions` and `loaders` runs in Horizon.
|
||||
pub fn support(kind: Kind, game_versions: &[String], loaders: &[String]) -> Support {
|
||||
match kind {
|
||||
@@ -268,20 +256,6 @@ pub fn support(kind: Kind, game_versions: &[String], loaders: &[String]) -> Supp
|
||||
Support::Partial("Lists no Minecraft version.".into())
|
||||
}
|
||||
}
|
||||
Kind::ShaderPack => {
|
||||
if loaders.iter().any(|l| SHADER_LOADERS.contains(&l.as_str())) {
|
||||
Support::Partial(
|
||||
"Java shader packs are translated: their full-screen passes draw, the world \
|
||||
and shadow passes don't yet (BSL and Complementary tested)."
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
let named = loaders.first().map(String::as_str).unwrap_or("unknown");
|
||||
Support::No(format!(
|
||||
"A shader pack for {named}: Horizon runs Iris and OptiFine packs only."
|
||||
))
|
||||
}
|
||||
}
|
||||
Kind::Modpack => {
|
||||
Support::Partial("Horizon runs the mods it can port, from your own downloads.".into())
|
||||
}
|
||||
@@ -351,7 +325,7 @@ pub fn pick(kind: Kind, versions: &[Version], everything: bool) -> Option<&Versi
|
||||
}
|
||||
let runs = |v: &&Version| match support(kind, &v.game_versions, &v.loaders) {
|
||||
Support::Works => true,
|
||||
// A modpack's version is for its own Minecraft; any shader pack Horizon translates.
|
||||
// A modpack's version is for its own Minecraft.
|
||||
Support::Partial(_) => kind != Kind::ResourcePack,
|
||||
Support::No(_) => false,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ pub struct CurseForge {
|
||||
fn class(kind: Kind) -> u64 {
|
||||
match kind {
|
||||
Kind::ResourcePack => 12,
|
||||
Kind::ShaderPack => 6552,
|
||||
Kind::Modpack => 4471,
|
||||
Kind::Mod => 6,
|
||||
}
|
||||
@@ -87,7 +86,7 @@ pub(super) fn search_url(query: &Query) -> String {
|
||||
url
|
||||
}
|
||||
|
||||
/// CurseForge mixes Minecraft versions and loader names (`Iris`, `OptiFine`, `NeoForge`) in one
|
||||
/// CurseForge mixes Minecraft versions and loader names (`Fabric`, `NeoForge`) in one
|
||||
/// list: digits first is a version, the rest are loaders, in lowercase as Modrinth names them.
|
||||
fn split_versions(list: &Value) -> (Vec<String>, Vec<String>) {
|
||||
let mut versions = Vec::new();
|
||||
@@ -122,19 +121,7 @@ pub(super) fn parse_mod(m: &Value) -> Option<Project> {
|
||||
.map(|v| Value::String(v.to_owned()))
|
||||
.collect(),
|
||||
);
|
||||
let (game_versions, mut loaders) = split_versions(&latest);
|
||||
// A shader pack's files name its loader; the index often doesn't.
|
||||
if kind == Kind::ShaderPack && loaders.is_empty() {
|
||||
let (_, own) = split_versions(&Value::Array(
|
||||
m["latestFiles"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|f| f["gameVersions"].as_array().cloned().unwrap_or_default())
|
||||
.collect(),
|
||||
));
|
||||
loaders = own;
|
||||
}
|
||||
let (game_versions, loaders) = split_versions(&latest);
|
||||
Some(Project {
|
||||
source: SourceId::CurseForge,
|
||||
id: m["id"].as_u64()?.to_string(),
|
||||
|
||||
@@ -75,7 +75,7 @@ impl Environment {
|
||||
pub struct Filters {
|
||||
/// Every one of these categories (`decoration`, `worldgen`...).
|
||||
pub categories: Vec<String>,
|
||||
/// One loader (`neoforge`, `fabric`, `iris`...), in place of the kind's defaults.
|
||||
/// One loader (`neoforge`, `fabric`...), in place of the kind's defaults.
|
||||
pub loader: Option<String>,
|
||||
/// One Minecraft version, in place of the one Horizon plays.
|
||||
pub game_version: Option<String>,
|
||||
@@ -279,12 +279,10 @@ pub fn modrinth_tags(
|
||||
}
|
||||
}
|
||||
|
||||
/// The loaders a kind lists by default, when no loader is picked: Java mod loaders for mods,
|
||||
/// Iris and OptiFine for shader packs (unless `everything`).
|
||||
pub fn default_loaders(kind: Kind, everything: bool) -> Vec<&'static str> {
|
||||
/// The loaders a kind lists by default, when no loader is picked: Java mod loaders for mods.
|
||||
pub fn default_loaders(kind: Kind) -> Vec<&'static str> {
|
||||
match kind {
|
||||
Kind::Mod => super::java::LOADERS.to_vec(),
|
||||
Kind::ShaderPack if !everything => vec!["iris", "optifine"],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -339,7 +337,7 @@ mod tests {
|
||||
]);
|
||||
let loaders = json!([
|
||||
{"name": "fabric", "supported_project_types": ["mod", "modpack"]},
|
||||
{"name": "iris", "supported_project_types": ["shader"]}
|
||||
{"name": "minecraft", "supported_project_types": ["resourcepack"]}
|
||||
]);
|
||||
let versions = json!([
|
||||
{"version": "26.3", "version_type": "release"},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! What the browser installed into the game folder, and installing, updating and removing it.
|
||||
//!
|
||||
//! `mod-browser.json` beside `options.txt` lists each file the browser put in `resourcepacks/`
|
||||
//! or `shaderpacks/`, and each Java mod it ported into `horizon-mods/`: where it came from, its
|
||||
//! version and its checksum. Files the player put
|
||||
//! there themselves are never touched: the browser only replaces or removes what it lists.
|
||||
//! `mod-browser.json` beside `options.txt` lists each file the browser put in `resourcepacks/`,
|
||||
//! and each Java mod it ported into `horizon-mods/`: where it came from, its version and its
|
||||
//! checksum. Files the player put there themselves are never touched: the browser only replaces
|
||||
//! or removes what it lists.
|
||||
use super::{pick, safe_name, Kind, Project, Source, SourceId, Support, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -43,9 +43,20 @@ pub struct Entry {
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Installed {
|
||||
/// An entry this launcher can't read (a shader pack, which older launchers installed) is
|
||||
/// left out, and its file left where it is.
|
||||
#[serde(deserialize_with = "readable_entries")]
|
||||
pub entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
fn readable_entries<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Entry>, D::Error> {
|
||||
let entries = Vec::<serde_json::Value>::deserialize(d)?;
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.filter_map(|e| serde_json::from_value(e).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl Installed {
|
||||
pub fn load(home: &Path) -> Self {
|
||||
fs::read_to_string(home.join(MANIFEST))
|
||||
|
||||
@@ -65,12 +65,7 @@ fn project(id: &str, kind: Kind) -> Project {
|
||||
license: "MIT".into(),
|
||||
kind,
|
||||
game_versions: vec![GAME.into()],
|
||||
loaders: vec![if kind == Kind::ShaderPack {
|
||||
"iris"
|
||||
} else {
|
||||
"minecraft"
|
||||
}
|
||||
.into()],
|
||||
loaders: vec!["minecraft".into()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,13 +212,6 @@ fn nothing_is_installed_when_a_check_fails() {
|
||||
assert!(remove(&home, "good").is_err());
|
||||
assert!(home.join("resourcepacks/good-1.zip").exists());
|
||||
|
||||
// What doesn't run is refused before anything is asked for.
|
||||
let mut canvas = project("canvas", Kind::ShaderPack);
|
||||
canvas.loaders = vec!["canvas".into()];
|
||||
fake.add(canvas.clone());
|
||||
assert!(install(&fake, &canvas, true, &home, NONE)
|
||||
.unwrap_err()
|
||||
.contains("doesn't run"));
|
||||
let pack = project("pack", Kind::Modpack);
|
||||
assert!(plan(&fake, &pack, false)
|
||||
.unwrap_err()
|
||||
@@ -232,14 +220,9 @@ fn nothing_is_installed_when_a_check_fails() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_packs_go_to_their_folder_and_modpacks_download_checked() {
|
||||
fn modpacks_download_checked() {
|
||||
let home = home("kinds");
|
||||
let mut fake = Fake::default();
|
||||
fake.add(project("bsl", Kind::ShaderPack));
|
||||
fake.release("bsl", "10", &[], b"shaders", false);
|
||||
install(&fake, &fake.project("bsl").unwrap(), false, &home, NONE).unwrap();
|
||||
assert!(home.join("shaderpacks/bsl-10.zip").is_file());
|
||||
|
||||
fake.add(project("atm", Kind::Modpack));
|
||||
fake.release("atm", "1", &[], b"mrpack", false);
|
||||
let downloads = home.join("downloads");
|
||||
@@ -332,3 +315,27 @@ fn java_mods_are_ported_turned_on_refused_on_clashes_and_removed() {
|
||||
);
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_packs_older_launchers_installed_are_left_alone() {
|
||||
let home = home("old-shader-pack");
|
||||
fs::create_dir_all(home.join("shaderpacks")).unwrap();
|
||||
fs::write(home.join("shaderpacks/old-pack.zip"), b"shaders").unwrap();
|
||||
let entry = |kind: &str, file: &str| {
|
||||
serde_json::json!({
|
||||
"source": "modrinth", "project": file, "slug": file, "title": file, "kind": kind,
|
||||
"version": "1", "number": "1", "file": file, "checksum": "SHA-512:00", "page": ""
|
||||
})
|
||||
};
|
||||
let manifest = serde_json::json!({ "entries": [
|
||||
entry("shader-pack", "shaderpacks/old-pack.zip"),
|
||||
entry("resource-pack", "resourcepacks/faithful-1.zip"),
|
||||
]});
|
||||
fs::write(home.join("mod-browser.json"), manifest.to_string()).unwrap();
|
||||
let installed = Installed::load(&home);
|
||||
assert_eq!(installed.entries.len(), 1);
|
||||
assert_eq!(installed.entries[0].kind, Kind::ResourcePack);
|
||||
installed.save(&home).unwrap();
|
||||
assert!(home.join("shaderpacks/old-pack.zip").is_file());
|
||||
fs::remove_dir_all(&home).unwrap();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ pub struct Modrinth;
|
||||
fn project_type(kind: Kind) -> &'static str {
|
||||
match kind {
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::ShaderPack => "shader",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::Mod => "mod",
|
||||
}
|
||||
@@ -36,7 +35,7 @@ pub(super) fn facets(query: &Query) -> String {
|
||||
}
|
||||
let loaders: Vec<String> = match &f.loader {
|
||||
Some(l) => vec![format!("categories:{l}")],
|
||||
None => details::default_loaders(query.kind, query.everything)
|
||||
None => details::default_loaders(query.kind)
|
||||
.iter()
|
||||
.map(|l| format!("categories:{l}"))
|
||||
.collect(),
|
||||
|
||||
@@ -46,12 +46,6 @@ fn support_by_kind() {
|
||||
old.reason().unwrap().starts_with("Made for 1.21.10:"),
|
||||
"{old:?}"
|
||||
);
|
||||
assert!(support(Kind::ShaderPack, &[], &v(&["iris"])).installable());
|
||||
assert!(support(Kind::ShaderPack, &[], &v(&["optifine"])).installable());
|
||||
let canvas = support(Kind::ShaderPack, &[], &v(&["canvas"]));
|
||||
assert!(!canvas.installable());
|
||||
assert!(canvas.reason().unwrap().contains("canvas"));
|
||||
assert!(!support(Kind::ShaderPack, &[], &[]).installable());
|
||||
assert!(support(Kind::Modpack, &v(&["1.21.1"]), &v(&["neoforge"])).installable());
|
||||
}
|
||||
|
||||
@@ -70,14 +64,6 @@ fn picks_the_newest_version_that_runs() {
|
||||
assert!(pick(Kind::ResourcePack, &old, false).is_none());
|
||||
assert_eq!(pick(Kind::ResourcePack, &old, true).unwrap().id, "old");
|
||||
|
||||
let shaders = [
|
||||
version("canvas", &[GAME], &["canvas"]),
|
||||
version("iris", &["1.21.1"], &["iris"]),
|
||||
];
|
||||
assert_eq!(pick(Kind::ShaderPack, &shaders, false).unwrap().id, "iris");
|
||||
let only_canvas = [version("canvas", &[GAME], &["canvas"])];
|
||||
assert!(pick(Kind::ShaderPack, &only_canvas, true).is_none());
|
||||
|
||||
let mut blocked = version("blocked", &[GAME], &["minecraft"]);
|
||||
blocked.blocked = Some("no".into());
|
||||
let mut empty = version("empty", &[GAME], &["minecraft"]);
|
||||
@@ -90,7 +76,7 @@ fn file_names_must_be_plain() {
|
||||
for good in [
|
||||
"Faithful 32x.zip",
|
||||
"FreshAnimations_v1.11.1_26.1+.zip",
|
||||
"BSL_v10.zip",
|
||||
"Faithful_v10.zip",
|
||||
] {
|
||||
assert_eq!(safe_name(good), Some(good));
|
||||
}
|
||||
@@ -122,12 +108,7 @@ fn modrinth_search_asks_for_what_runs() {
|
||||
url.contains("&index=relevance&") && url.ends_with("&query=faithful"),
|
||||
"{url}"
|
||||
);
|
||||
let shaders = Query::new("", Kind::ShaderPack);
|
||||
assert_eq!(
|
||||
modrinth::facets(&shaders),
|
||||
r#"[["project_type:shader"],["categories:iris","categories:optifine"]]"#
|
||||
);
|
||||
assert!(modrinth::search_url(&shaders).contains("&index=downloads&"));
|
||||
assert!(modrinth::search_url(&Query::new("", Kind::ResourcePack)).contains("&index=downloads&"));
|
||||
let everything = Query {
|
||||
everything: true,
|
||||
..Query::new("", Kind::ResourcePack)
|
||||
@@ -169,7 +150,7 @@ fn java_mods_are_checked_for_1_21_and_later() {
|
||||
}
|
||||
let versions = [
|
||||
version("snapshot", &["26.4-snapshot-1"], &["fabric"]),
|
||||
version("shaders", &[GAME], &["iris"]),
|
||||
version("plugin", &[GAME], &["bukkit"]),
|
||||
version("now", &[GAME], &["neoforge"]),
|
||||
version("older", &["1.21.1"], &["neoforge"]),
|
||||
];
|
||||
@@ -200,23 +181,22 @@ fn modrinth_answers() {
|
||||
assert_eq!(sodium.kind, Kind::Mod);
|
||||
assert_eq!(sodium.page, "https://modrinth.com/mod/sodium");
|
||||
assert!(matches!(sodium.support(), Support::Partial(_)));
|
||||
// Data packs and plugins are not.
|
||||
let plugin = json!({"project_id": "x", "project_type": "plugin", "slug": "p"});
|
||||
assert!(modrinth::parse_hit(&plugin).is_none());
|
||||
// Data packs, plugins and shader packs are not.
|
||||
for project_type in ["plugin", "shader"] {
|
||||
let hit = json!({"project_id": "x", "project_type": project_type, "slug": "p"});
|
||||
assert!(modrinth::parse_hit(&hit).is_none(), "{project_type}");
|
||||
}
|
||||
|
||||
let project = json!({
|
||||
"id": "HVnmMxH1", "project_type": "shader", "slug": "complementary-reimagined",
|
||||
"title": "Complementary Shaders - Reimagined", "description": "d",
|
||||
"license": {"id": "LicenseRef-Complementary", "name": ""},
|
||||
"game_versions": ["1.21.1"], "loaders": ["iris", "optifine"]
|
||||
"id": "HVnmMxH1", "project_type": "resourcepack", "slug": "fresh-animations",
|
||||
"title": "Fresh Animations", "description": "d",
|
||||
"license": {"id": "LicenseRef-Custom", "name": ""},
|
||||
"game_versions": ["26.3"], "loaders": ["minecraft"]
|
||||
});
|
||||
let p = modrinth::parse_project(&project).unwrap();
|
||||
assert_eq!(p.kind, Kind::ShaderPack);
|
||||
assert_eq!(p.license, "LicenseRef-Complementary");
|
||||
assert_eq!(
|
||||
p.page,
|
||||
"https://modrinth.com/shader/complementary-reimagined"
|
||||
);
|
||||
assert_eq!(p.kind, Kind::ResourcePack);
|
||||
assert_eq!(p.license, "LicenseRef-Custom");
|
||||
assert_eq!(p.page, "https://modrinth.com/resourcepack/fresh-animations");
|
||||
assert!(p.support().installable());
|
||||
|
||||
let version = json!({
|
||||
@@ -259,33 +239,32 @@ fn curseforge_stays_off_without_a_key() {
|
||||
|
||||
#[test]
|
||||
fn curseforge_answers() {
|
||||
let q = Query::new("bsl", Kind::ShaderPack);
|
||||
let q = Query::new("faithful", Kind::ResourcePack);
|
||||
let url = curseforge::search_url(&q);
|
||||
assert!(url.contains("gameId=432&classId=6552&"), "{url}");
|
||||
assert!(url.contains("gameId=432&classId=12&"), "{url}");
|
||||
assert!(
|
||||
!url.contains("gameVersion=") && url.ends_with("&searchFilter=bsl"),
|
||||
url.contains("&gameVersion=26.3") && url.ends_with("&searchFilter=faithful"),
|
||||
"{url}"
|
||||
);
|
||||
assert!(
|
||||
curseforge::search_url(&Query::new("", Kind::ResourcePack)).contains("&gameVersion=26.3")
|
||||
);
|
||||
|
||||
let m = json!({
|
||||
"id": 228756, "classId": 6552, "slug": "bsl-shaders", "name": "BSL Shaders",
|
||||
"summary": "s", "authors": [{"name": "CaptTatsu"}], "downloadCount": 1.5e7,
|
||||
"links": {"websiteUrl": "https://www.curseforge.com/minecraft/shaders/bsl-shaders"},
|
||||
"id": 238222, "classId": 6, "slug": "jei", "name": "Just Enough Items",
|
||||
"summary": "s", "authors": [{"name": "mezz"}], "downloadCount": 1.5e7,
|
||||
"links": {"websiteUrl": "https://www.curseforge.com/minecraft/mc-mods/jei"},
|
||||
"logo": {"thumbnailUrl": "https://media.forgecdn.net/avatars/thumbnails/x.png"},
|
||||
"latestFilesIndexes": [{"gameVersion": "1.21.1"}],
|
||||
"latestFiles": [{"gameVersions": ["1.21.1", "Iris", "OptiFine"]}]
|
||||
"latestFilesIndexes": [{"gameVersion": "1.21.1"}, {"gameVersion": "NeoForge"}]
|
||||
});
|
||||
let p = curseforge::parse_mod(&m).unwrap();
|
||||
assert_eq!(
|
||||
(p.source, p.id.as_str(), p.kind),
|
||||
(SourceId::CurseForge, "228756", Kind::ShaderPack)
|
||||
(SourceId::CurseForge, "238222", Kind::Mod)
|
||||
);
|
||||
assert_eq!(p.loaders, ["iris", "optifine"]);
|
||||
assert_eq!(p.author, "CaptTatsu");
|
||||
assert_eq!(p.game_versions, ["1.21.1"]);
|
||||
assert_eq!(p.loaders, ["neoforge"]);
|
||||
assert_eq!(p.author, "mezz");
|
||||
assert!(p.support().installable());
|
||||
// Shader packs (class 6552) are not offered.
|
||||
assert!(curseforge::parse_mod(&json!({"id": 1, "classId": 6552})).is_none());
|
||||
assert_eq!(
|
||||
curseforge::parse_mod(&json!({"id": 1, "classId": 6})).map(|p| p.kind),
|
||||
Some(Kind::Mod)
|
||||
@@ -293,15 +272,15 @@ fn curseforge_answers() {
|
||||
assert!(curseforge::parse_mod(&json!({"id": 1, "classId": 17})).is_none());
|
||||
|
||||
let file = json!({
|
||||
"id": 5000, "displayName": "BSL v10", "fileName": "BSL_v10.zip", "fileLength": 300,
|
||||
"fileDate": "2026-01-01T00:00:00Z", "downloadUrl": "https://edge.forgecdn.net/files/5/0/BSL_v10.zip",
|
||||
"gameVersions": ["1.21.1", "Iris"],
|
||||
"id": 5000, "displayName": "JEI 19", "fileName": "jei-19.jar", "fileLength": 300,
|
||||
"fileDate": "2026-01-01T00:00:00Z", "downloadUrl": "https://edge.forgecdn.net/files/5/0/jei-19.jar",
|
||||
"gameVersions": ["1.21.1", "NeoForge"],
|
||||
"hashes": [{"value": "D41D8CD9", "algo": 2}, {"value": "DA39A3EE", "algo": 1}],
|
||||
"dependencies": [{"modId": 7, "relationType": 3}, {"modId": 8, "relationType": 2}]
|
||||
});
|
||||
let ver = curseforge::parse_file(&file).unwrap();
|
||||
assert_eq!(ver.required, ["7"]);
|
||||
assert_eq!(ver.loaders, ["iris"]);
|
||||
assert_eq!(ver.loaders, ["neoforge"]);
|
||||
assert_eq!(ver.file().unwrap().checksum(), Some(("SHA-1", "da39a3ee")));
|
||||
assert!(ver.blocked.is_none());
|
||||
let mut closed = file.clone();
|
||||
|
||||
+1
-3
@@ -205,7 +205,6 @@ enum ModsCmd {
|
||||
enum KindArg {
|
||||
Mod,
|
||||
Resource,
|
||||
Shader,
|
||||
Modpack,
|
||||
}
|
||||
|
||||
@@ -339,7 +338,7 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
action: Option<ModpackCmd>,
|
||||
},
|
||||
/// The mod browser: Java mods, resource packs, shader packs and modpacks from Modrinth
|
||||
/// The mod browser: Java mods, resource packs and modpacks from Modrinth
|
||||
/// (CurseForge support is coming soon), each file checked against its checksum
|
||||
/// (docs/mod-browser.md)
|
||||
Mods {
|
||||
@@ -1100,7 +1099,6 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
|
||||
} => {
|
||||
let kind = match kind {
|
||||
KindArg::Resource => Kind::ResourcePack,
|
||||
KindArg::Shader => Kind::ShaderPack,
|
||||
KindArg::Modpack => Kind::Modpack,
|
||||
KindArg::Mod => Kind::Mod,
|
||||
};
|
||||
|
||||
@@ -393,14 +393,15 @@ mod tests {
|
||||
assert_eq!(schema["inputSchema"]["properties"]["text"]["type"], "array");
|
||||
let kinds = &schema["inputSchema"]["properties"]["kind"]["enum"];
|
||||
assert!(
|
||||
kinds.as_array().unwrap().contains(&json!("shader")),
|
||||
kinds.as_array().unwrap().contains(&json!("modpack"))
|
||||
&& !kinds.as_array().unwrap().contains(&json!("shader")),
|
||||
"{kinds}"
|
||||
);
|
||||
assert_eq!(
|
||||
search
|
||||
.argv(&json!({ "text": ["fresh", "animations"], "kind": "shader", "everything": true }))
|
||||
.argv(&json!({ "text": ["fresh", "animations"], "kind": "resource", "everything": true }))
|
||||
.unwrap(),
|
||||
["mods", "search", "--kind=shader", "--everything", "fresh", "animations"]
|
||||
["mods", "search", "--kind=resource", "--everything", "fresh", "animations"]
|
||||
);
|
||||
let run = tool("servers_run");
|
||||
assert_eq!(
|
||||
|
||||
@@ -42,7 +42,7 @@ use theme::Theme;
|
||||
enum Page {
|
||||
Play,
|
||||
Versions,
|
||||
/// Mods, resource and shader packs, and modpacks (imported ones and the browser's).
|
||||
/// Mods, resource packs and modpacks (imported ones and the browser's).
|
||||
Mods,
|
||||
Servers,
|
||||
Notes,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The Mods page: search Modrinth (CurseForge: coming soon) for Java mods, modpacks, resource
|
||||
//! packs and shader packs, see where each comes from and how well it runs (a Java mod's jar is
|
||||
//! The Mods page: search Modrinth (CurseForge: coming soon) for Java mods, modpacks and resource
|
||||
//! packs, see where each comes from and how well it runs (a Java mod's jar is
|
||||
//! checked first), install it with its checksum checked, and update or remove what was installed
|
||||
//! (`horizon_launcher::browser`). Imported modpacks are listed on its Modpacks tab.
|
||||
use super::servers_page::{type_key, Key};
|
||||
@@ -12,11 +12,10 @@ use horizon_port_pack::check::Check;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The kinds, as the Mods page's tabs: name and icon.
|
||||
const KINDS: [(PackKind, &str, &str); 4] = [
|
||||
const KINDS: [(PackKind, &str, &str); 3] = [
|
||||
(PackKind::Mod, "Mods", "box"),
|
||||
(PackKind::Modpack, "Modpacks", "archive"),
|
||||
(PackKind::ResourcePack, "Resource packs", "image"),
|
||||
(PackKind::ShaderPack, "Shader packs", "zap"),
|
||||
];
|
||||
/// Java mods checked at once.
|
||||
const CHECKING: usize = 2;
|
||||
@@ -391,12 +390,9 @@ impl Launcher {
|
||||
}
|
||||
));
|
||||
}
|
||||
let turn_on = match p.kind {
|
||||
PackKind::ShaderPack => "Video Settings > Shader Packs",
|
||||
_ => "Options > Resource Packs",
|
||||
};
|
||||
Ok(format!(
|
||||
"Installed {}, checksums checked. Turn it on in the game: {turn_on}.",
|
||||
"Installed {}, checksums checked. Turn it on in the game: Options > \
|
||||
Resource Packs.",
|
||||
names.join(", ")
|
||||
))
|
||||
});
|
||||
@@ -479,7 +475,7 @@ impl Launcher {
|
||||
}
|
||||
let header = page_header(
|
||||
"Mods",
|
||||
"Java mods, modpacks, resource packs and shader packs. Horizon ports what a Java mod \
|
||||
"Java mods, modpacks and resource packs. Horizon ports what a Java mod \
|
||||
carries (blocks, items, recipes, worldgen) and says before you install what runs. \
|
||||
Each file is checked against its source's checksum before it reaches your game \
|
||||
folder.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user