mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Merge remote-tracking branch 'origin/main' into m10283
# Conflicts: # crates/horizon-client/src/entity_nbt.rs
This commit is contained in:
commit
8b583a15e1
1368 files changed
+137118
-17640
No files matched your search
@@ -234,14 +234,15 @@ jobs:
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
|
||||
# `$output = & launcher --version` read no output from it (run 42217014588).
|
||||
shell: bash
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
python ../tools/check_imports.py "$launcher"
|
||||
out=$("$launcher" --version)
|
||||
echo "$out"
|
||||
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
|
||||
- name: Build a native-mod game with the launcher's managed toolchain
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
|
||||
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -92,8 +93,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -105,8 +106,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
- A harness = `tools/test_<name>_parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (mostly horizon-world and horizon-client),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
|
||||
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio, controllers, `discord`, `friends` and `heartbeat` (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
@@ -151,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| 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`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `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)) |
|
||||
@@ -176,7 +177,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
- `struct Game` (game.rs) holds everything. Its methods are spread over many files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
@@ -190,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
+5
-1
@@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
|
||||
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
|
||||
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
|
||||
and compares their outputs. There are about 290 of them.
|
||||
and compares their outputs.
|
||||
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
|
||||
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
|
||||
to download a mod or a pack does not give you the right to read its files.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
|
||||
your commit counts. In doubt, don't: ask in #contributors first.
|
||||
|
||||
Generated
+254
-105
@@ -131,9 +131,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"log",
|
||||
@@ -142,17 +142,32 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -237,6 +252,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -485,6 +506,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -540,6 +562,12 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -946,6 +974,18 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1105,8 +1145,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,6 +1177,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.2"
|
||||
@@ -1264,6 +1316,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
@@ -1365,6 +1426,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"dunce",
|
||||
"flate2",
|
||||
"fs4",
|
||||
@@ -1447,6 +1509,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"getrandom 0.3.4",
|
||||
@@ -1506,7 +1569,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1575,7 +1638,7 @@ dependencies = [
|
||||
name = "horizon-cosmetics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"glam",
|
||||
"png",
|
||||
"serde",
|
||||
@@ -1603,7 +1666,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1660,10 +1723,15 @@ dependencies = [
|
||||
name = "horizon-mod-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gif",
|
||||
"mysql",
|
||||
"quinn",
|
||||
"redis",
|
||||
"rusqlite",
|
||||
"rusty-opus",
|
||||
"snap",
|
||||
"tungstenite",
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1703,6 +1771,14 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1742,6 +1818,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1757,7 +1834,7 @@ dependencies = [
|
||||
name = "horizon-release-sign"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ring",
|
||||
]
|
||||
|
||||
@@ -1766,7 +1843,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
@@ -1797,7 +1874,7 @@ dependencies = [
|
||||
name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
@@ -2001,12 +2078,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2268,6 +2345,17 @@ dependencies = [
|
||||
"redox_syscall 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libudev-sys"
|
||||
version = "0.1.4"
|
||||
@@ -2319,9 +2407,15 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
@@ -2414,10 +2508,11 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mysql"
|
||||
version = "27.0.0"
|
||||
version = "28.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
|
||||
checksum = "ca3de8a73b7c70f76dbcdd4b6abc9b53bbb6c4f7bd6c79d8e8a7ea2157c02487"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bufstream",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
@@ -2441,15 +2536,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.36.2"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
|
||||
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.2",
|
||||
"btoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
@@ -2484,7 +2580,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-ident",
|
||||
@@ -2498,7 +2594,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -3060,7 +3156,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"cms",
|
||||
"der",
|
||||
@@ -3112,11 +3208,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -3354,6 +3450,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3401,6 +3553,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -3539,12 +3700,32 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.41.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3619,6 +3800,7 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3883,6 +4065,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snap"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4199,12 +4387,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4274,6 +4472,9 @@ name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
@@ -4374,7 +4575,7 @@ version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -4412,6 +4613,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
@@ -4675,6 +4882,12 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "30.0.1"
|
||||
@@ -4728,7 +4941,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
@@ -5051,15 +5264,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -5093,30 +5297,13 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
@@ -5138,12 +5325,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5156,12 +5337,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5174,24 +5349,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5204,12 +5367,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5222,12 +5379,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5240,12 +5391,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5258,12 +5403,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
@@ -5471,6 +5610,16 @@ version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"hashlink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
|
||||
+17
-1
@@ -37,6 +37,7 @@ members = [
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
@@ -47,7 +48,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.95"
|
||||
|
||||
# Ports mirror vanilla's Java signatures, index loops and nested generic types
|
||||
# one to one so they can be checked against the decompiled sources.
|
||||
@@ -81,6 +82,21 @@ libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# The native mod API is generated by a build script that parses every file of game/ with syn on
|
||||
# each gameplay edit; unoptimized that took ~6 s of every rebuild, optimized ~1.5 s.
|
||||
[profile.dev.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.dev.package.syn]
|
||||
opt-level = 3
|
||||
[profile.dev.package.proc-macro2]
|
||||
opt-level = 3
|
||||
[profile.release.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.release.package.syn]
|
||||
opt-level = 3
|
||||
[profile.release.package.proc-macro2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -49,6 +49,18 @@ Everything below is in the current release.
|
||||
|
||||
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
|
||||
|
||||
## Performance
|
||||
|
||||
**Horizon draws more frames per second than Java Edition, and loads faster, on the same machine, world and path.** `tools/perf_bench.py` runs each game in turn, from a fresh world (seed 1234), waiting for the terrain and then flying it over a village, a forest, an ocean and a mountain. Measured on 2026-10-29/30 on an idle Windows 11 machine (Ryzen 7 9800X3D, RTX 5080), Horizon v0.2.97 as the launcher installs it, 1280x720 in a window, VSync off, three games per figure. Frames per second, mean of the four scenes, with the slowest 1% of frames in brackets:
|
||||
|
||||
| Render distance | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 chunks | 3,289 (1,327) | 2,437 (640) | 2,375 (709) |
|
||||
| 16 chunks | 2,074 (598) | 1,661 (375) | 1,586 (432) |
|
||||
| 32 chunks | 1,207 (333) | 1,031 (176) | 879 (264) |
|
||||
|
||||
"Vanilla + mods" is Sodium, Lithium, FerriteCore and ImmediatelyFast. Horizon reaches the title screen in 2.9–3.2 s against vanilla's 7.5–7.7 s (the modded client's 10.8–11.1 s), loads a scene's chunks 2.9–3.8 times faster, and playing holds a steady working set of 1.1–1.7 GB against 1.9–2.9 GB. Every scene with its confidence interval and the run's load log: [the 2026-10-29 reports](benchmarks/perf/windows-9800x3d-5080-2026-10-29.md). Method, and how to measure your own machine: [docs/benchmark.md](docs/benchmark.md). [playhorizon.dev/benchmarks](https://playhorizon.dev/benchmarks) puts these runs beside a 2016 laptop, a Mac mini and a gaming desktop.
|
||||
|
||||
## Download
|
||||
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
|
||||
|
||||
-10
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
|
||||
|
||||
</details>
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
+99
-2
@@ -1,12 +1,56 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
|
||||
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
|
||||
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
|
||||
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
|
||||
development-only oracle calls the unchanged helper from the player's local artifact. No
|
||||
original classes, game shaders, textures or prepared resources are distributed here.
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). Its GL backend also reports
|
||||
the context's sampled textures and samplers per shader stage (usually 32 on desktop GL,
|
||||
where WebGL2's minimum 16 was reported before), so a pipeline layout the driver accepts is
|
||||
not refused. It is licensed under MIT or Apache-2.0; both licenses are in that folder
|
||||
(`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## naga
|
||||
|
||||
@@ -212,3 +256,56 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
```
|
||||
|
||||
## Just Enough Items
|
||||
|
||||
The Recipes GUI sprites and settings icon under `crates/horizon-client-render/src/jei_recipes` are from
|
||||
[Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems/tree/0371fa6fafa14635a356019e4604f5580b26a34f)
|
||||
by mezz and contributors, under the MIT license in that directory (`LICENSE.txt`).
|
||||
|
||||
## Flywheel native model directional shading
|
||||
|
||||
The two-light diffuse expression and transformed-normal policy in
|
||||
`terrain.wgsl`'s native model entries follow Flywheel 1.0.6
|
||||
(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`).
|
||||
No Flywheel classes, compiled shaders or original model/texture assets are bundled.
|
||||
|
||||
Copyright (c) 2021-2024 Jozufozu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -3,7 +3,7 @@ name = "cargo-horizon-mod"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
rust-version.workspace = true
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value {
|
||||
.collect();
|
||||
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
|
||||
}
|
||||
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "shared by fixtures which normalize different model stages"
|
||||
)]
|
||||
pub fn model(v: &CuboidModel) -> Value {
|
||||
let textures: BTreeMap<_, _> = v
|
||||
.textures
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct ModelQuad {
|
||||
/// NeoForge's `render_type` names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderType {
|
||||
/// A declared render type unknown to this baker; distinct from an absent default.
|
||||
Unsupported,
|
||||
Solid,
|
||||
Cutout,
|
||||
CutoutMipped,
|
||||
@@ -234,7 +236,10 @@ fn bake_into(
|
||||
skip_flat: bool,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
let render_type = model
|
||||
.render_type
|
||||
.as_deref()
|
||||
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
|
||||
for element in model.elements() {
|
||||
let element = element
|
||||
.as_ref()
|
||||
@@ -292,7 +297,10 @@ fn bake_obj(
|
||||
transform: BlockModelTransform,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
let render_type = model
|
||||
.render_type
|
||||
.as_deref()
|
||||
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
|
||||
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
|
||||
let slot = match obj.materials.get(&face.material) {
|
||||
Some(MtlTexture::Sprite(sprite)) => {
|
||||
|
||||
@@ -179,19 +179,19 @@ impl Condition {
|
||||
let fields = object(raw)?;
|
||||
if fields.len() == 1 {
|
||||
for name in ["OR", "AND"] {
|
||||
if let Some(raw) = fields.get(name) {
|
||||
if let Ok(values) = array(raw).and_then(|values| {
|
||||
if let Some(raw) = fields.get(name)
|
||||
&& let Ok(values) = array(raw).and_then(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(Self::decode)
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}) {
|
||||
return Ok(if name == "OR" {
|
||||
Self::Or(values)
|
||||
} else {
|
||||
Self::And(values)
|
||||
});
|
||||
}
|
||||
})
|
||||
{
|
||||
return Ok(if name == "OR" {
|
||||
Self::Or(values)
|
||||
} else {
|
||||
Self::And(values)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +917,46 @@ impl NumericProperty {
|
||||
/// (`None` removes a prototype component).
|
||||
pub type Components = BTreeMap<String, Value>;
|
||||
|
||||
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
|
||||
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
|
||||
/// sword's are a few dozen JSON values, on every draw of it).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ComponentsView<'a> {
|
||||
prototype: &'a Components,
|
||||
patch: &'a BTreeMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl<'a> ComponentsView<'a> {
|
||||
pub fn get(&self, key: &str) -> Option<&'a Value> {
|
||||
match self.patch.get(key) {
|
||||
Some(patched) => patched.as_ref(),
|
||||
None => self.prototype.get(key),
|
||||
}
|
||||
}
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
/// Components with no patch: `components` as they are.
|
||||
pub fn of(components: &'a Components) -> Self {
|
||||
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
|
||||
Self {
|
||||
prototype: components,
|
||||
patch: &NO_PATCH,
|
||||
}
|
||||
}
|
||||
/// The merged map, for what needs one whole.
|
||||
pub fn to_map(&self) -> Components {
|
||||
let mut out = self.prototype.clone();
|
||||
for (key, value) in self.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An item stack as the resolver reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackView {
|
||||
@@ -1087,14 +1127,15 @@ impl ItemModels {
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
std::borrow::Cow::Owned(self.components_view(stack).to_map())
|
||||
}
|
||||
/// The stack's components, read in place (see [`ComponentsView`]).
|
||||
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
ComponentsView {
|
||||
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
|
||||
patch: &stack.patch,
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1112,7 +1153,7 @@ impl ItemModels {
|
||||
ctx: &Context,
|
||||
env: &dyn ItemEnv,
|
||||
) {
|
||||
let components = self.components(stack);
|
||||
let components = self.components_view(stack);
|
||||
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
|
||||
return;
|
||||
};
|
||||
@@ -1122,7 +1163,7 @@ impl ItemModels {
|
||||
let mut state = Resolve {
|
||||
models: self,
|
||||
stack,
|
||||
components: &components,
|
||||
components,
|
||||
ctx,
|
||||
env,
|
||||
};
|
||||
@@ -1140,7 +1181,7 @@ impl ItemModels {
|
||||
self.properties(stack).2
|
||||
}
|
||||
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
|
||||
match self.components(stack).get("minecraft:item_model") {
|
||||
match self.components_view(stack).get("minecraft:item_model") {
|
||||
Some(Value::String(id)) => identifier(id)
|
||||
.ok()
|
||||
.and_then(|id| self.definitions.get(&id))
|
||||
@@ -1159,7 +1200,7 @@ impl ItemModels {
|
||||
struct Resolve<'a> {
|
||||
models: &'a ItemModels,
|
||||
stack: &'a StackView,
|
||||
components: &'a Components,
|
||||
components: ComponentsView<'a>,
|
||||
ctx: &'a Context<'a>,
|
||||
env: &'a dyn ItemEnv,
|
||||
}
|
||||
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
|
||||
fn numeric(&self, property: &NumericProperty) -> f32 {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
NumericProperty::BundleFullness => {
|
||||
self.env.bundle_fullness(self.stack, self.components)
|
||||
}
|
||||
NumericProperty::BundleFullness => self
|
||||
.env
|
||||
.bundle_fullness(self.stack, &self.components.to_map()),
|
||||
NumericProperty::Compass {
|
||||
wobble,
|
||||
target,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
components: ComponentsView<'_>,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
@@ -143,13 +143,17 @@ mod tests {
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{}]}),
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
@@ -166,12 +170,21 @@ mod tests {
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &NoEnv),
|
||||
Some(false)
|
||||
);
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn identifier_adds_default_namespace() {
|
||||
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond_sword".into(),
|
||||
Components::from_iter([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
json!("minecraft:diamond_sword"),
|
||||
),
|
||||
("minecraft:max_damage".into(), json!(1561)),
|
||||
("minecraft:rarity".into(), json!("common")),
|
||||
]),
|
||||
);
|
||||
let mut stack = StackView::new("minecraft:diamond_sword", 1);
|
||||
stack.patch.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
Some(json!({"minecraft:sharpness": 5})),
|
||||
);
|
||||
stack
|
||||
.patch
|
||||
.insert("minecraft:rarity".into(), Some(json!("rare")));
|
||||
stack.patch.insert("minecraft:max_damage".into(), None);
|
||||
let view = models.components_view(&stack);
|
||||
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
|
||||
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
|
||||
assert_eq!(
|
||||
view.get("minecraft:item_model"),
|
||||
Some(&json!("minecraft:diamond_sword"))
|
||||
);
|
||||
assert!(view.contains_key("minecraft:enchantments"));
|
||||
assert!(!view.contains_key("minecraft:custom_name"));
|
||||
// Every key reads as the merged map has it.
|
||||
let merged = models.components(&stack);
|
||||
for key in [
|
||||
"minecraft:rarity",
|
||||
"minecraft:max_damage",
|
||||
"minecraft:item_model",
|
||||
"minecraft:enchantments",
|
||||
"minecraft:custom_name",
|
||||
] {
|
||||
assert_eq!(view.get(key), merged.get(key), "{key}");
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
@@ -290,12 +290,12 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
pub fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
|
||||
@@ -777,6 +777,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_unknown_declared_render_type_remains_distinct_from_absent() {
|
||||
let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material");
|
||||
let library = library(&[("block/custom", &json)]);
|
||||
let model = library.resolve("block/custom").unwrap();
|
||||
let quads = crate::bake::bake_block_model(
|
||||
&model,
|
||||
crate::bake::BlockModelTransform::new([0; 3], false),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!quads.is_empty());
|
||||
assert!(
|
||||
quads
|
||||
.iter()
|
||||
.all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
|
||||
let library = library(&[
|
||||
("block/base", r#"{"render_type":"translucent"}"#),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT"
|
||||
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
|
||||
|
||||
|
||||
+142
-18
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--seed N default 12345
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--pvp B fight bots also attack each other (true or false); default true
|
||||
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
|
||||
other swing, landing it while falling: a critical hit, then a sweep from the
|
||||
ground (give them swords); for PvP benches (crit and sweep particles)
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
@@ -165,6 +168,8 @@ struct Options {
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
|
||||
crits: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
crits: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_swing = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--crits" {
|
||||
o.crits = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
|
||||
const SWORD_COOLDOWN_TICKS: u32 = 13;
|
||||
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
|
||||
const JUMP_POWER: f64 = 0.42;
|
||||
|
||||
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
|
||||
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
|
||||
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
|
||||
#[derive(Default)]
|
||||
struct Crits {
|
||||
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
|
||||
jump: Option<f64>,
|
||||
ground: f64,
|
||||
/// The next swing is the jumped one.
|
||||
crit_next: bool,
|
||||
}
|
||||
|
||||
impl Crits {
|
||||
fn airborne(&self) -> bool {
|
||||
self.jump.is_some()
|
||||
}
|
||||
|
||||
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
|
||||
fn tick(
|
||||
&mut self,
|
||||
pos: &mut [f64; 3],
|
||||
target: (i32, [f64; 3]),
|
||||
since: &mut u32,
|
||||
) -> Option<i32> {
|
||||
if let Some(v) = self.jump {
|
||||
pos[1] += v;
|
||||
if pos[1] <= self.ground {
|
||||
pos[1] = self.ground;
|
||||
self.jump = None;
|
||||
} else {
|
||||
self.jump = Some((v - 0.08) * 0.98);
|
||||
}
|
||||
} else {
|
||||
self.ground = pos[1];
|
||||
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
|
||||
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
|
||||
self.jump = Some(JUMP_POWER);
|
||||
}
|
||||
}
|
||||
let falling = self.jump.is_some_and(|v| v < 0.0);
|
||||
if self.crit_next && !falling {
|
||||
*since = since.saturating_add(1);
|
||||
fight_step(pos, target.1);
|
||||
return None;
|
||||
}
|
||||
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
|
||||
if hit.is_some() {
|
||||
self.crit_next = !self.crit_next;
|
||||
}
|
||||
hit
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
|
||||
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
|
||||
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
|
||||
fn fight_tick(
|
||||
pos: &mut [f64; 3],
|
||||
target: (i32, [f64; 3]),
|
||||
since_attack: &mut u32,
|
||||
cooldown: u32,
|
||||
) -> Option<i32> {
|
||||
*since_attack = since_attack.saturating_add(1);
|
||||
let (id, at) = target;
|
||||
fight_step(pos, at);
|
||||
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
|
||||
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
|
||||
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
|
||||
let horizontal = dx.hypot(dz);
|
||||
if horizontal > CLOSE_IN {
|
||||
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
|
||||
pos[0] += dx / horizontal * step;
|
||||
pos[2] += dz / horizontal * step;
|
||||
}
|
||||
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
|
||||
*since_attack = 0;
|
||||
return Some(id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
|
||||
@@ -616,7 +694,7 @@ fn costly(
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
if i.is_multiple_of(2) {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
@@ -645,7 +723,7 @@ fn costly(
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
let text = if i.is_multiple_of(2) {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
@@ -1124,6 +1202,7 @@ fn run_bot(
|
||||
spawn[1] + o.height
|
||||
};
|
||||
let mut since_attack = ATTACK_COOLDOWN_TICKS;
|
||||
let mut crits = Crits::default();
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
@@ -1181,11 +1260,16 @@ fn run_bot(
|
||||
Behaviour::Fight => {
|
||||
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
|
||||
if let Some(near) = near {
|
||||
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
|
||||
let hit = if o.crits {
|
||||
crits.tick(&mut pos, near, &mut since_attack)
|
||||
} else {
|
||||
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
|
||||
};
|
||||
if let Some(victim) = hit {
|
||||
// The client sends the attack, then swings the arm.
|
||||
let sent = send(
|
||||
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
|
||||
) && send(raw(sb::PUNCH, |w| w.varint(0)));
|
||||
) && send(play::Serverbound::Punch(Empty).encode());
|
||||
if sent {
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -1300,9 +1384,10 @@ fn run_bot(
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
// Fight bots stand on the ground; the others fly.
|
||||
// Fight bots stand on the ground (but in a --crits jump); the others fly.
|
||||
flags: u8::from(
|
||||
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
|
||||
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|
||||
|| o.ground,
|
||||
),
|
||||
})
|
||||
.encode(),
|
||||
@@ -1470,10 +1555,10 @@ fn main() {
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
println!("{summary:#}");
|
||||
} else if let Some(path) = &o.report {
|
||||
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
} else if let Some(path) = &o.report
|
||||
&& let Err(e) = std::fs::write(path, format!("{summary:#}\n"))
|
||||
{
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
let errors: Vec<String> = bots
|
||||
.iter()
|
||||
@@ -1600,7 +1685,9 @@ mod tests {
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let hits: Vec<u32> = (0..40)
|
||||
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
|
||||
.filter_map(|tick| {
|
||||
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
|
||||
assert!(
|
||||
@@ -1617,7 +1704,7 @@ mod tests {
|
||||
let mut since = ATTACK_COOLDOWN_TICKS;
|
||||
let mut first = None;
|
||||
for tick in 0..100 {
|
||||
if fight_tick(&mut pos, target, &mut since).is_some() {
|
||||
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
|
||||
first = Some(tick);
|
||||
break;
|
||||
}
|
||||
@@ -1627,6 +1714,43 @@ mod tests {
|
||||
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
|
||||
let mut pos = [0.0, 64.0, 0.0];
|
||||
let target = (3, [1.0, 64.0, 0.0]);
|
||||
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
|
||||
let mut last_y = pos[1];
|
||||
let mut hits = Vec::new();
|
||||
for tick in 0..120u32 {
|
||||
let hit = crits.tick(&mut pos, target, &mut since);
|
||||
if hit.is_some() {
|
||||
hits.push((tick, crits_state(pos[1], last_y)));
|
||||
}
|
||||
last_y = pos[1];
|
||||
}
|
||||
assert!(hits.len() >= 6, "{hits:?}");
|
||||
for (i, w) in hits.windows(2).enumerate() {
|
||||
assert!(
|
||||
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
|
||||
"full strength each time: {hits:?}"
|
||||
);
|
||||
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
|
||||
}
|
||||
// A crit lands in the air, on the way down; a sweep from the ground.
|
||||
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
|
||||
assert!(pos[1] >= 64.0);
|
||||
}
|
||||
|
||||
fn crits_state(y: f64, before: f64) -> &'static str {
|
||||
if y == 64.0 {
|
||||
"ground"
|
||||
} else if y < before {
|
||||
"falling"
|
||||
} else {
|
||||
"rising"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -217,9 +217,10 @@ pub fn stack(tag: &Tag) -> Option<Tag> {
|
||||
Some(Tag::compound(out))
|
||||
}
|
||||
/// `ItemStackTemplate.CODEC`: an item id or `{id, count?, components?}`.
|
||||
/// Encoding uses the primary record form, including for a bare item id.
|
||||
fn template(tag: &Tag) -> Option<Tag> {
|
||||
match tag {
|
||||
Tag::String(_) => Some(tag.clone()),
|
||||
Tag::String(_) => Some(Tag::compound([("id", tag.clone())])),
|
||||
Tag::Compound(_) => {
|
||||
let mut out = vec![("id", string(&text(tag.get("id"))?))];
|
||||
if let Some(count) = int(tag.get("count")).filter(|c| *c != 1) {
|
||||
@@ -1011,3 +1012,69 @@ mod tests {
|
||||
assert_eq!(items[1].get("count"), Some(&Tag::Int(5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pot_template_tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
|
||||
#[test]
|
||||
fn decorated_pot_string_template_load_save_uses_item_compound() {
|
||||
let parse = |s: &str| parse_snbt(s).expect("valid input NBT").0;
|
||||
let input = |front: &str| {
|
||||
let mut tag = parse(
|
||||
r#"{id:"minecraft:decorated_pot",x:4,y:200,z:4,sherds:{back:{id:"minecraft:brick"},right:{id:"minecraft:archer_pottery_sherd"}},item:{id:"minecraft:wheat",count:3,components:{"minecraft:custom_name":"H290 item"}}}"#,
|
||||
);
|
||||
let Tag::Compound(root) = &mut tag else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(Tag::Compound(sides)) = root.get_mut(&"sherds".into()) else {
|
||||
unreachable!()
|
||||
};
|
||||
sides.insert("front".into(), parse(front));
|
||||
tag
|
||||
};
|
||||
let other_fields = |loaded: &Tag, saved: &Tag| {
|
||||
assert_eq!(saved.get("id"), loaded.get("id"));
|
||||
for key in ["x", "y", "z", "item"] {
|
||||
assert_eq!(saved.get(key), loaded.get(key), "{key}");
|
||||
}
|
||||
let loaded_sides = loaded.get("sherds").unwrap();
|
||||
let saved_sides = saved.get("sherds").unwrap();
|
||||
for side in ["back", "right"] {
|
||||
assert_eq!(saved_sides.get(side), loaded_sides.get(side), "{side}");
|
||||
}
|
||||
assert_eq!(saved_sides.get("left"), None);
|
||||
};
|
||||
let plain = r#"{id:"minecraft:angler_pottery_sherd"}"#;
|
||||
let defaults = r#"{id:"minecraft:angler_pottery_sherd",count:1,components:{}}"#;
|
||||
let rich = r#"{id:"minecraft:angler_pottery_sherd",count:3,components:{"minecraft:custom_data":{h290:1},"minecraft:custom_name":"H290 sherd"}}"#;
|
||||
// Actual canonical load/save entry, not the private template helper.
|
||||
for (front, expected) in [(plain, plain), (defaults, plain), (rich, rich)] {
|
||||
let loaded = input(front);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(expected))
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
}
|
||||
println!(
|
||||
"H290 actual canonical compound/default/count/components, other sides/item/name and idempotency controls admitted"
|
||||
);
|
||||
let loaded = input(r#""minecraft:angler_pottery_sherd""#);
|
||||
let original = loaded.clone();
|
||||
let saved = canonical(&loaded).expect("actual decorated-pot codec");
|
||||
other_fields(&loaded, &saved);
|
||||
assert_eq!(loaded, original, "read-only canonicalization");
|
||||
assert_eq!(
|
||||
saved.get("sherds").unwrap().get("front"),
|
||||
Some(&parse(plain)),
|
||||
"string template must save the primary item compound",
|
||||
);
|
||||
assert_eq!(canonical(&saved).as_ref(), Some(&saved));
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,25 @@ impl Registries {
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// Add the damage catalog's types ([`Self::damage_types`]: content mods'
|
||||
/// and the world's packs' with vanilla's) to the commands'
|
||||
/// `minecraft:damage_type` ids, which the export lists for vanilla alone.
|
||||
/// `ResourceArgument` looks a type up in the server's registries, so
|
||||
/// `/damage` takes every type the world has. The export's tags stay. The
|
||||
/// ids are replaced, not extended: `by_registry` outlives a world, and a
|
||||
/// pack's type must not stay accepted in the next world opened.
|
||||
pub fn sync_damage_type_ids(&mut self) {
|
||||
if self.damage_types.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut ids = self.damage_types.clone();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
self.by_registry
|
||||
.entry("minecraft:damage_type".into())
|
||||
.or_default()
|
||||
.ids = ids;
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
@@ -312,3 +331,49 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Registries;
|
||||
|
||||
/// A content mod's damage type joins vanilla's in the commands' ids, the
|
||||
/// export's tags stay, and a second world open lists each type once.
|
||||
#[test]
|
||||
fn damage_type_ids_take_the_catalogs_types() {
|
||||
let export = Registries::from_export(&serde_json::json!({
|
||||
"by_registry": {"minecraft:damage_type": {
|
||||
"ids": ["minecraft:fall", "minecraft:magic"],
|
||||
"tags": ["minecraft:is_fall"],
|
||||
}},
|
||||
}));
|
||||
let mut reg = Registries {
|
||||
damage_types: vec![
|
||||
"minecraft:magic".into(),
|
||||
"example:test".into(),
|
||||
"minecraft:fall".into(),
|
||||
],
|
||||
..Registries::default()
|
||||
};
|
||||
reg.fill_from_exported(&export);
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["minecraft:fall", "minecraft:magic"],
|
||||
"the export alone lists vanilla's types"
|
||||
);
|
||||
for _ in 0..2 {
|
||||
reg.sync_damage_type_ids();
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["example:test", "minecraft:fall", "minecraft:magic"]
|
||||
);
|
||||
assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]);
|
||||
}
|
||||
// The next world, without the pack: its type is no longer accepted.
|
||||
reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()];
|
||||
reg.sync_damage_type_ids();
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["minecraft:fall", "minecraft:magic"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -490,6 +490,37 @@ impl GlyphStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much the gameplay guide says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GuideDetail {
|
||||
#[default]
|
||||
Full,
|
||||
/// Only what can be done to what's in front of the player or under the crosshair.
|
||||
Basic,
|
||||
Off,
|
||||
}
|
||||
impl GuideDetail {
|
||||
pub const ALL: [GuideDetail; 3] = [GuideDetail::Full, GuideDetail::Basic, GuideDetail::Off];
|
||||
|
||||
/// The name in `horizon-controller.txt`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GuideDetail::Full => "full",
|
||||
GuideDetail::Basic => "basic",
|
||||
GuideDetail::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a name, or the `true` and `false` this option first saved. Anything else is full.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value {
|
||||
"basic" => GuideDetail::Basic,
|
||||
"off" | "false" => GuideDetail::Off,
|
||||
_ => GuideDetail::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard A opens on a text field in focus mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TextKeyboard {
|
||||
@@ -537,6 +568,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
|
||||
pub struct PadSettings {
|
||||
pub enabled: bool,
|
||||
pub vibration: bool,
|
||||
/// The pad keeps driving the game while its window has no focus.
|
||||
pub play_in_background: bool,
|
||||
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
|
||||
pub adaptive_triggers: bool,
|
||||
pub lightbar: Lightbar,
|
||||
@@ -556,6 +589,8 @@ pub struct PadSettings {
|
||||
/// by default on the Switch only.
|
||||
pub nintendo_letters: bool,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// The gameplay guide: what the buttons do now, in the HUD's corners while the pad plays.
|
||||
pub guide_detail: GuideDetail,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
pub scroll_speed: f32,
|
||||
@@ -569,6 +604,7 @@ impl Default for PadSettings {
|
||||
Self {
|
||||
enabled: true,
|
||||
vibration: true,
|
||||
play_in_background: false,
|
||||
adaptive_triggers: true,
|
||||
lightbar: Lightbar::Health,
|
||||
look_sensitivity: 0.5,
|
||||
@@ -581,6 +617,7 @@ impl Default for PadSettings {
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
guide_detail: GuideDetail::Full,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
repeat_delay: 0.35,
|
||||
@@ -635,6 +672,7 @@ impl PadSettings {
|
||||
match key.trim() {
|
||||
"enabled" => s.enabled = flag(s.enabled),
|
||||
"vibration" => s.vibration = flag(s.vibration),
|
||||
"playInBackground" => s.play_in_background = flag(s.play_in_background),
|
||||
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
|
||||
"lightbar" => s.lightbar = Lightbar::parse(value),
|
||||
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
|
||||
@@ -649,6 +687,7 @@ impl PadSettings {
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"gameplayGuide" => s.guide_detail = GuideDetail::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
@@ -706,7 +745,8 @@ impl PadSettings {
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
self.guide_detail.name(),
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
for (action, button) in &self.bindings {
|
||||
@@ -715,6 +755,10 @@ impl PadSettings {
|
||||
button.map_or("none", Button::name)
|
||||
));
|
||||
}
|
||||
text.push_str(&format!(
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
@@ -786,7 +830,8 @@ pub fn stick_axes(
|
||||
{
|
||||
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
|
||||
// is the right trigger and must not drive look.
|
||||
(left, [left_z, right[0]])
|
||||
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
|
||||
(left, [left_z, -right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
@@ -1752,6 +1797,15 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_in_the_background_is_off_until_saved_on() {
|
||||
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
|
||||
let mut on = PadSettings::default();
|
||||
assert!(!on.play_in_background);
|
||||
on.play_in_background = true;
|
||||
assert!(PadSettings::parse(&on.serialize()).play_in_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_these_defaults_keeps_its_buttons() {
|
||||
// Saved settings that never bound D-pad down: it stays on the advancements.
|
||||
@@ -1793,6 +1847,7 @@ bind_key.hotbar.2:north
|
||||
s.look_sensitivity = 0.75;
|
||||
s.glyphs = GlyphStyle::PlayStation;
|
||||
s.nintendo_letters = !s.nintendo_letters;
|
||||
s.guide_detail = GuideDetail::Basic;
|
||||
let text = s.serialize();
|
||||
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
|
||||
assert!(text.contains("bind_key.jump:east\n"));
|
||||
@@ -1820,7 +1875,11 @@ bind_key.hotbar.2:north
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
assert_eq!(
|
||||
right,
|
||||
[-0.5, -0.7],
|
||||
"X from LeftZ, Y from RightStickX, turned over"
|
||||
);
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
//! The gameplay guide: what the buttons do right now, in the corners of the HUD while a pad plays
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verb {
|
||||
Use,
|
||||
Open,
|
||||
Ride,
|
||||
Trade,
|
||||
Feed,
|
||||
Place,
|
||||
Eat,
|
||||
Drink,
|
||||
Shoot,
|
||||
Load,
|
||||
Throw,
|
||||
Raise,
|
||||
Equip,
|
||||
Fish,
|
||||
Zoom,
|
||||
Boost,
|
||||
}
|
||||
impl Verb {
|
||||
fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Verb::Use => "horizon.controller.guide.use",
|
||||
Verb::Open => "horizon.controller.guide.open",
|
||||
Verb::Ride => "horizon.controller.guide.ride",
|
||||
Verb::Trade => "horizon.controller.guide.trade",
|
||||
Verb::Feed => "horizon.controller.guide.feed",
|
||||
Verb::Place => "horizon.controller.guide.place",
|
||||
Verb::Eat => "horizon.controller.guide.eat",
|
||||
Verb::Drink => "horizon.controller.guide.drink",
|
||||
Verb::Shoot => "horizon.controller.guide.shoot",
|
||||
Verb::Load => "horizon.controller.guide.load",
|
||||
Verb::Throw => "horizon.controller.guide.throw",
|
||||
Verb::Raise => "horizon.controller.guide.raise",
|
||||
Verb::Equip => "horizon.controller.guide.equip",
|
||||
Verb::Fish => "horizon.controller.guide.fish",
|
||||
Verb::Zoom => "horizon.controller.guide.zoom",
|
||||
Verb::Boost => "horizon.controller.guide.boost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.hitResult`, with what each button would do to it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
#[default]
|
||||
None,
|
||||
Block {
|
||||
breakable: bool,
|
||||
/// The block takes the use before the held item gets its turn.
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
Entity {
|
||||
attackable: bool,
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An item in use, by what letting go of it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Active {
|
||||
Shoot,
|
||||
Throw,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// What the guide knows of the player this moment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
pub spectator: bool,
|
||||
pub target: Target,
|
||||
/// What `Item.use` does on the target or in the air: the main hand's, else the off hand's.
|
||||
pub item_use: Option<Verb>,
|
||||
/// Pick Block finds something it can select or, in creative, add.
|
||||
pub can_pick: bool,
|
||||
pub using: Option<Active>,
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
pub hands_busy: bool,
|
||||
pub riding: bool,
|
||||
/// The vehicle jumps when told (a saddled horse).
|
||||
pub vehicle_jumps: bool,
|
||||
pub on_ground: bool,
|
||||
pub in_water: bool,
|
||||
pub flying: bool,
|
||||
pub may_fly: bool,
|
||||
pub gliding: bool,
|
||||
/// In the air with a glider on: a jump would start gliding.
|
||||
pub can_glide: bool,
|
||||
/// Sneaking that the button latched, so the next press stands up.
|
||||
pub sneak_latched: bool,
|
||||
pub can_sprint: bool,
|
||||
pub main_hand_item: bool,
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two columns, most useful row first.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Guide {
|
||||
/// Top left: movement and the player's own actions.
|
||||
pub left: Vec<Row>,
|
||||
/// Top right: break, use and pick block.
|
||||
pub right: Vec<Row>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// The rows this detail keeps. Basic is the right column: what the buttons do to what's in
|
||||
/// front of the player, or the plain face buttons in an inventory.
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
GuideDetail::Basic => Guide {
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
left: Vec::new(),
|
||||
right: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
Guide {
|
||||
left: [
|
||||
row("key.jump", jump_caption(facts)),
|
||||
row("key.sneak", sneak_caption(facts)),
|
||||
row("key.sprint", facts.can_sprint.then_some("key.sprint")),
|
||||
row("key.inventory", player.then_some("key.inventory")),
|
||||
row(
|
||||
"key.drop",
|
||||
(player && facts.main_hand_item).then_some("horizon.controller.guide.drop"),
|
||||
),
|
||||
row(
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
right: [
|
||||
row("key.attack", attack_caption(facts)),
|
||||
row("key.use", use_caption(facts)),
|
||||
row("key.pickItem", pick_caption(facts)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
f.vehicle_jumps.then_some("key.jump")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_up")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water {
|
||||
Some("horizon.controller.guide.swim_up")
|
||||
} else if f.on_ground {
|
||||
Some("key.jump")
|
||||
} else if f.can_glide {
|
||||
Some("horizon.controller.guide.glide")
|
||||
} else {
|
||||
f.may_fly.then_some("horizon.controller.guide.fly")
|
||||
}
|
||||
}
|
||||
|
||||
fn sneak_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
Some("horizon.controller.guide.dismount")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_down")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water && !f.on_ground {
|
||||
Some("horizon.controller.guide.swim_down")
|
||||
} else if f.sneak_latched {
|
||||
Some("horizon.controller.guide.stand")
|
||||
} else {
|
||||
Some("key.sneak")
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.handleKeybinds` spends attack clicks while an item is in use, and
|
||||
/// `startAttack` does nothing with busy hands.
|
||||
fn attack_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.using.is_some() || f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
match f.target {
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} if f.spectator => Some("horizon.controller.guide.spectate"),
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} => Some("horizon.controller.guide.attack"),
|
||||
Target::Block {
|
||||
breakable: true, ..
|
||||
} => Some("horizon.controller.guide.break"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`: what is aimed at first, then the item.
|
||||
fn use_caption(f: &Facts) -> Option<&'static str> {
|
||||
match f.using {
|
||||
Some(Active::Shoot) => return Some("horizon.controller.guide.release_shoot"),
|
||||
Some(Active::Throw) => return Some("horizon.controller.guide.release_throw"),
|
||||
Some(Active::Stop) => return Some("horizon.controller.guide.release_stop"),
|
||||
None => {}
|
||||
}
|
||||
if f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
let takes = match f.target {
|
||||
Target::Block { takes, .. } | Target::Entity { takes, .. } => takes,
|
||||
Target::None => None,
|
||||
};
|
||||
takes.or(f.item_use).map(Verb::caption)
|
||||
}
|
||||
|
||||
fn pick_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.spectator {
|
||||
// key.spectatorHotbar is this button: it opens the teleport menu.
|
||||
return Some("horizon.controller.guide.spectator_menu");
|
||||
}
|
||||
(f.can_pick && f.using.is_none()).then_some("key.pickItem")
|
||||
}
|
||||
|
||||
/// What the crosshair is on in an inventory.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Under {
|
||||
#[default]
|
||||
Nothing,
|
||||
/// A slot. `same` is the carried stack being its kind; `result` a crafting result, which is
|
||||
/// only ever taken whole.
|
||||
Slot {
|
||||
item: bool,
|
||||
same: bool,
|
||||
result: bool,
|
||||
/// The carried stack can go here.
|
||||
accepts: bool,
|
||||
},
|
||||
Recipe {
|
||||
direct_craft: bool,
|
||||
alternatives: bool,
|
||||
},
|
||||
/// One of the recipe book's other controls.
|
||||
Control,
|
||||
}
|
||||
|
||||
/// What an inventory's rows are resolved from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MenuFacts {
|
||||
pub under: Under,
|
||||
pub carrying: bool,
|
||||
/// The recipe book is showing: the bumpers turn its pages, the triggers its categories.
|
||||
pub book: bool,
|
||||
/// The bumpers switch tabs.
|
||||
pub tabs: bool,
|
||||
}
|
||||
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
// A result takes only into empty hands or the same stack.
|
||||
Under::Slot {
|
||||
item: true,
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
Under::Recipe {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
/// A row's height and the view's margin, in GUI pixels.
|
||||
pub const ROW_HEIGHT: f32 = 11.0;
|
||||
const MARGIN: f32 = 2.0;
|
||||
|
||||
fn overlap(a: &Rect, b: &Rect) -> bool {
|
||||
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
|
||||
}
|
||||
|
||||
/// Where a column's rows go, down from a top corner of the `[width, height]` view: under whatever
|
||||
/// a row would overlap in `taken`, or nowhere when that leaves the view's upper three quarters (the
|
||||
/// row is dropped: the rows come in the order they matter). The placed rows join `taken`.
|
||||
pub fn place_column(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, false, widths, taken)
|
||||
}
|
||||
|
||||
/// `place_column`'s rows up from a bottom corner, over what they would overlap, within the view's
|
||||
/// lower quarter: the first row is the lowest.
|
||||
pub fn place_column_up(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, true, widths, taken)
|
||||
}
|
||||
|
||||
fn place(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
up: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
let mut y = if up {
|
||||
view[1] - MARGIN - ROW_HEIGHT
|
||||
} else {
|
||||
MARGIN
|
||||
};
|
||||
widths
|
||||
.iter()
|
||||
.map(|&width| {
|
||||
if width > view[0] - 2.0 * MARGIN {
|
||||
return None;
|
||||
}
|
||||
let x = if left {
|
||||
MARGIN
|
||||
} else {
|
||||
view[0] - MARGIN - width
|
||||
};
|
||||
let mut at = y;
|
||||
loop {
|
||||
let outside = if up {
|
||||
at < view[1] * 0.75
|
||||
} else {
|
||||
at + ROW_HEIGHT > view[1] * 0.75
|
||||
};
|
||||
if outside {
|
||||
return None;
|
||||
}
|
||||
let rect = [x, at, width, ROW_HEIGHT];
|
||||
let hit = taken.iter().filter(|t| overlap(&rect, t));
|
||||
let past = if up {
|
||||
hit.map(|t| t[1]).fold(f32::INFINITY, f32::min)
|
||||
} else {
|
||||
hit.map(|t| t[1] + t[3]).fold(f32::NEG_INFINITY, f32::max)
|
||||
};
|
||||
if past.is_infinite() {
|
||||
taken.push(rect);
|
||||
y = if up {
|
||||
at - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
at + ROW_HEIGHT + 1.0
|
||||
};
|
||||
return Some(rect);
|
||||
}
|
||||
at = if up {
|
||||
past.floor() - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
past.ceil() + 1.0
|
||||
};
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn walking() -> Facts {
|
||||
Facts {
|
||||
on_ground: true,
|
||||
can_sprint: true,
|
||||
main_hand_item: true,
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
fn on_block(f: Facts, breakable: bool, takes: Option<Verb>) -> Facts {
|
||||
Facts {
|
||||
target: Target::Block { breakable, takes },
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survival_on_a_block_shows_break_use_and_pick_on_the_default_buttons() {
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
(Button::DPadLeft, "key.pickItem"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
(Button::LeftStick, "key.sprint"),
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebound_action_moves_its_row_and_an_unbound_one_loses_it() {
|
||||
let mut settings = PadSettings::default();
|
||||
settings.bind("key.attack", Some(Button::North));
|
||||
settings.bind("key.pickItem", None);
|
||||
settings.bind("key.jump", Some(Button::Start));
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
assert!(!captions(&guide.left).contains(&"key.jump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_block_or_entity_takes_comes_before_the_held_item() {
|
||||
let settings = PadSettings::default();
|
||||
let chest = Facts {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&chest, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.break",
|
||||
"horizon.controller.guide.open"
|
||||
]
|
||||
);
|
||||
let villager = Facts {
|
||||
item_use: Some(Verb::Eat),
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: Some(Verb::Trade),
|
||||
},
|
||||
..walking()
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&villager, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.attack",
|
||||
"horizon.controller.guide.trade"
|
||||
]
|
||||
);
|
||||
// A mob the item means nothing to: the item is used on.
|
||||
let cow = Facts {
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..villager
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&cow, &settings).right)[1],
|
||||
"horizon.controller.guide.eat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_do_leaves_the_right_column_empty() {
|
||||
let settings = PadSettings::default();
|
||||
assert!(resolve(&walking(), &settings).right.is_empty());
|
||||
// An unbreakable block too (bedrock, a creative sword, adventure without can_break).
|
||||
assert!(
|
||||
resolve(&on_block(walking(), false, None), &settings)
|
||||
.right
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_in_use_leaves_only_letting_go() {
|
||||
let facts = Facts {
|
||||
using: Some(Active::Shoot),
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&facts, &PadSettings::default()).right),
|
||||
["horizon.controller.guide.release_shoot"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_hands_neither_attack_nor_use() {
|
||||
let facts = Facts {
|
||||
hands_busy: true,
|
||||
riding: true,
|
||||
item_use: Some(Verb::Eat),
|
||||
..on_block(Facts::default(), true, None)
|
||||
};
|
||||
assert!(resolve(&facts, &PadSettings::default()).right.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_follows_what_the_player_is_doing() {
|
||||
let settings = PadSettings::default();
|
||||
let first_two = |f: Facts| {
|
||||
let left = resolve(&f, &settings).left;
|
||||
captions(&left).into_iter().take(2).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
in_water: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.swim_up",
|
||||
"horizon.controller.guide.swim_down"
|
||||
]
|
||||
);
|
||||
// In the air, only a glider or creative flight makes a jump do anything.
|
||||
assert_eq!(first_two(Facts::default()), ["key.sneak", "key.inventory"]);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
can_glide: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.glide"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
sneak_latched: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[1],
|
||||
"horizon.controller.guide.stand"
|
||||
);
|
||||
// A boat can't jump; a saddled horse can; both dismount.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.dismount"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
vehicle_jumps: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.jump", "horizon.controller.guide.dismount"]
|
||||
);
|
||||
// Gliding: neither jump nor sneak does anything.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
main_hand_item: true,
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..Facts::default()
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
captions(&guide.right),
|
||||
[
|
||||
"horizon.controller.guide.spectate",
|
||||
"horizon.controller.guide.spectator_menu"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_from_their_corner() {
|
||||
let mut taken = Vec::new();
|
||||
let left = place_column([200.0, 200.0], true, &[50.0, 30.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 2.0, 50.0, 11.0]));
|
||||
assert_eq!(left[1], Some([2.0, 14.0, 30.0, 11.0]));
|
||||
let right = place_column([200.0, 200.0], false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 2.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_goes_under_what_it_would_overlap() {
|
||||
// The effect icons in the top right corner.
|
||||
let mut taken = vec![[150.0, 1.0, 50.0, 24.0]];
|
||||
let right = place_column([200.0, 200.0], false, &[40.0, 40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 26.0, 40.0, 11.0]));
|
||||
assert_eq!(right[1], Some([158.0, 38.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_columns_of_a_narrow_view_never_overlap_and_late_rows_drop() {
|
||||
// One quarter of a small window, a boss bar across its top: the rows have 31..67 left.
|
||||
let view = [160.0, 90.0];
|
||||
let bar = [0.0, 12.0, 160.0, 19.0];
|
||||
let mut taken = vec![bar];
|
||||
let right = place_column(view, false, &[90.0, 90.0, 90.0], &mut taken);
|
||||
let left = place_column(view, true, &[90.0; 6], &mut taken);
|
||||
let placed: Vec<Rect> = right.iter().chain(&left).flatten().copied().collect();
|
||||
for (i, a) in placed.iter().enumerate() {
|
||||
assert!(a[1] + a[3] <= 67.5 && a[0] >= 0.0 && a[0] + a[2] <= 160.0);
|
||||
assert!(!overlap(a, &bar));
|
||||
for b in &placed[i + 1..] {
|
||||
assert!(!overlap(a, b), "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
// Three rows' worth of room, and the right column came first.
|
||||
assert_eq!(right.iter().flatten().count(), 3);
|
||||
assert_eq!(left.iter().flatten().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_up_from_a_bottom_corner_and_drop_above_its_quarter() {
|
||||
let view = [200.0, 100.0];
|
||||
let mut taken = vec![[0.0, 80.0, 60.0, 5.0]];
|
||||
let left = place_column_up(view, true, &[50.0, 50.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 87.0, 50.0, 11.0]));
|
||||
// Over the panel at 80 would be 68, under the lower quarter's 75.
|
||||
assert_eq!(left[1], None);
|
||||
let right = place_column_up(view, false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 87.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
fn short(rows: &[Row]) -> Vec<&str> {
|
||||
rows.iter()
|
||||
.map(|r| r.caption.rsplit('.').next().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_face_buttons_go_right_and_the_rest_left() {
|
||||
let on = |under, carrying| {
|
||||
resolve_menu(&MenuFacts {
|
||||
under,
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
};
|
||||
let slot = |item, same, result| Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts: true,
|
||||
};
|
||||
let close_only = ["close"];
|
||||
let empty: [&str; 0] = [];
|
||||
for quiet in [Under::Nothing, slot(false, false, false)] {
|
||||
let guide = on(quiet, false);
|
||||
assert_eq!(short(&guide.right), close_only);
|
||||
assert_eq!(short(&guide.left), empty);
|
||||
}
|
||||
let item = on(slot(true, false, false), false);
|
||||
assert_eq!(
|
||||
short(&item.right),
|
||||
["close", "take", "take_half", "quick_move"]
|
||||
);
|
||||
assert_eq!(short(&item.left), ["drop", "drop_stack"]);
|
||||
assert_eq!(
|
||||
short(&on(slot(false, false, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, false), true).right),
|
||||
["close", "swap_stack", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, true, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, true), false).right),
|
||||
["close", "take", "quick_move"]
|
||||
);
|
||||
let recipe = on(
|
||||
Under::Recipe {
|
||||
direct_craft: true,
|
||||
alternatives: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&recipe.right),
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
alternatives: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&plain.right),
|
||||
["close", "place_recipe", "place_recipe_all"]
|
||||
);
|
||||
assert!(plain.left.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_screen_rows_follow_its_book_and_tabs() {
|
||||
let left = |book, tabs| {
|
||||
resolve_menu(&MenuFacts {
|
||||
book,
|
||||
tabs,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
.left
|
||||
};
|
||||
assert!(left(false, false).is_empty());
|
||||
assert_eq!(short(&left(true, false)), ["pages", "categories"]);
|
||||
assert_eq!(short(&left(false, true)), ["tabs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_wider_than_the_view_is_dropped() {
|
||||
let mut taken = Vec::new();
|
||||
assert_eq!(
|
||||
place_column([50.0, 200.0], true, &[60.0, 20.0], &mut taken),
|
||||
[None, Some([2.0, 2.0, 20.0, 11.0])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod detail_tests {
|
||||
use super::*;
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_keeps_both_columns() {
|
||||
let guide = rows().at_detail(GuideDetail::Full);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_keeps_the_right_column() {
|
||||
let guide = rows().at_detail(GuideDetail::Basic);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_keeps_nothing() {
|
||||
let guide = rows().at_detail(GuideDetail::Off);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_saved_with_the_old_switch_keeps_its_meaning() {
|
||||
assert_eq!(GuideDetail::parse("true"), GuideDetail::Full);
|
||||
assert_eq!(GuideDetail::parse("false"), GuideDetail::Off);
|
||||
assert_eq!(GuideDetail::parse("basic"), GuideDetail::Basic);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts,
|
||||
},
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
});
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_that_refuses_the_carried_stack_offers_nothing() {
|
||||
assert_eq!(rows(false, false, false, false, true), 0);
|
||||
assert_eq!(rows(true, true, false, false, true), 0);
|
||||
assert!(rows(false, false, false, true, true) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_is_taken_only_into_empty_hands_or_the_same_stack() {
|
||||
assert!(rows(true, false, true, true, false) > 0);
|
||||
assert!(rows(true, true, true, true, true) > 0);
|
||||
assert_eq!(rows(true, false, true, true, true), 0);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod actions;
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
|
||||
@@ -393,38 +393,17 @@ impl FullscreenState {
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
if wanted && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => enter_fullscreen(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
} else {
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None if reapply || mode_change => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None if reapply || mode_change => enter_fullscreen(
|
||||
window,
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +581,34 @@ fn monitor_current_mode(
|
||||
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
|
||||
}
|
||||
|
||||
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
|
||||
/// state first: winit keeps a maximized window's client area inside the work area, so a
|
||||
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
|
||||
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
|
||||
#[cfg(target_os = "windows")]
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen));
|
||||
}
|
||||
|
||||
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
|
||||
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
|
||||
winit::window::Fullscreen::Borderless(
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A window created fullscreen that the system also marked maximized enters it again
|
||||
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
|
||||
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
|
||||
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
|
||||
enter_fullscreen(window, borderless_fullscreen(window));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
|
||||
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
|
||||
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
|
||||
|
||||
@@ -6,7 +6,7 @@ autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
@@ -42,7 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -73,9 +74,10 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
@@ -112,12 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
|
||||
heartbeat = ["horizon-telemetry/live"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
@@ -26,7 +26,7 @@ horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Headless production brewing-fuel component fixture.
|
||||
use horizon_client_render::{
|
||||
brewing::{Brewing, BrewingStand, brewing_fuel},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_items::{
|
||||
item_data::decode_components,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
io::{BufRead, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let providers = Providers::load(root);
|
||||
let brewing = Brewing {
|
||||
recipes: vec![],
|
||||
reagents: Default::default(),
|
||||
inputs: Default::default(),
|
||||
};
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?);
|
||||
for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(&line?)?;
|
||||
let mut stack = ItemStack::new(
|
||||
input["item"].as_str().unwrap(),
|
||||
input["count"].as_u64().unwrap() as u32,
|
||||
);
|
||||
let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
stack.extra = extra;
|
||||
let accepted = brewing_fuel(&items, &stack).is_some();
|
||||
let mut stand = BrewingStand::new();
|
||||
// A newly constructed vanilla block entity has no saved total yet.
|
||||
stand.total_fuel = 0;
|
||||
stand.items[4] = Some(stack);
|
||||
for _ in 0..2 {
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {} {} {} {}",
|
||||
accepted,
|
||||
stand.fuel,
|
||||
stand.total_fuel,
|
||||
stand.speed.to_bits(),
|
||||
stand.items[4].as_ref().map_or(0, |s| s.count)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
|
||||
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.translucent_batching,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
@@ -243,6 +245,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.fxaa,
|
||||
settings.taa,
|
||||
settings.upscale,
|
||||
0.85,
|
||||
);
|
||||
let mut notes = Vec::new();
|
||||
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
|
||||
|
||||
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
assert!(
|
||||
@@ -348,6 +350,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -47,6 +47,53 @@ struct Channel {
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
|
||||
unmuffled: bool,
|
||||
}
|
||||
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
|
||||
const MUFFLED_CUTOFF: f32 = 300.0;
|
||||
/// The cutoff it sweeps down from as its strength rises.
|
||||
const OPEN_CUTOFF: f32 = 20_000.0;
|
||||
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
|
||||
const MUFFLE_GLIDE: f32 = 0.02;
|
||||
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
|
||||
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
|
||||
/// (never set, or faded out) the sounds mix straight into the output, as without it.
|
||||
#[derive(Default)]
|
||||
struct Muffle {
|
||||
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
|
||||
target: [f32; 2],
|
||||
/// Where the output is now, gliding to `target`.
|
||||
current: [f32; 2],
|
||||
/// The low-pass stages' state for the left and right output channels.
|
||||
stages: [[f32; 2]; 2],
|
||||
/// The muffled sounds of one buffer, reused.
|
||||
bus: Vec<f32>,
|
||||
}
|
||||
impl Muffle {
|
||||
fn active(&self) -> bool {
|
||||
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
|
||||
}
|
||||
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
|
||||
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
|
||||
let rate = rate as f32;
|
||||
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
|
||||
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
|
||||
for (current, target) in self.current.iter_mut().zip(self.target) {
|
||||
*current += (target - *current) * glide;
|
||||
}
|
||||
let [amount, lowpass] = self.current;
|
||||
let gain = 1.0 - amount;
|
||||
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
|
||||
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
|
||||
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
|
||||
stage[0] += (s - stage[0]) * a;
|
||||
stage[1] += (stage[0] - stage[1]) * a;
|
||||
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
|
||||
*out += (s + (stage[1] - s) * lowpass) * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -65,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -100,6 +149,21 @@ pub struct Heard {
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MuffleFade {
|
||||
amount: f32,
|
||||
lowpass: f32,
|
||||
ticks: u32,
|
||||
left: u32,
|
||||
}
|
||||
impl MuffleFade {
|
||||
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
|
||||
fn level(&self) -> [f32; 2] {
|
||||
let left = self.left as f32 / self.ticks as f32;
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -247,6 +311,8 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
|
||||
muffle: Option<MuffleFade>,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
@@ -261,6 +327,40 @@ pub struct Audio {
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
/// The look at the output devices running on its own thread, until a tick
|
||||
/// takes its answer: listing them can take 100 ms (#11490).
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: Option<PendingProbe>,
|
||||
}
|
||||
/// What one look at the output devices found: the system default, the
|
||||
/// fingerprint, and the device list when those say it may have changed.
|
||||
#[cfg(feature = "audio")]
|
||||
struct DeviceProbe {
|
||||
default: Option<String>,
|
||||
cards: Option<String>,
|
||||
devices: Option<Vec<String>>,
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
|
||||
/// Looks at the output devices as `should_change_device` did on the render
|
||||
/// thread: the device list only when the default or the fingerprint moved.
|
||||
#[cfg(feature = "audio")]
|
||||
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them; on Windows
|
||||
// every endpoint, with no fingerprint to skip it); only the fingerprint's
|
||||
// devices can change the list, and it is cheap.
|
||||
let devices = (default != default_seen
|
||||
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
|
||||
.then(output_devices);
|
||||
DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
}
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
|
||||
use symphonia::core::{
|
||||
@@ -405,6 +505,7 @@ impl Backend for MixerBackend {
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -432,6 +533,9 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -668,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
|
||||
let muffling = mixer.muffle.active();
|
||||
let mut bus = std::mem::take(&mut mixer.muffle.bus);
|
||||
if muffling {
|
||||
bus.clear();
|
||||
bus.resize(data.len(), 0.0);
|
||||
}
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -677,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
&mut *data
|
||||
};
|
||||
for frame in out.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
@@ -712,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
mixer.voices = voices;
|
||||
if muffling {
|
||||
mixer.muffle.apply(&bus, data, channels, rate);
|
||||
} else {
|
||||
// Off: the next muffle starts from silence, not from the last one's filter.
|
||||
mixer.muffle.current = [0.0; 2];
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -1037,12 +1161,13 @@ impl Audio {
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: 1.0,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1051,6 +1176,8 @@ impl Audio {
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -1196,12 +1323,13 @@ impl Audio {
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: self.master,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
@@ -1211,6 +1339,8 @@ impl Audio {
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -1232,7 +1362,8 @@ impl Audio {
|
||||
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
|
||||
/// default changed (with no device chosen in the options), or the chosen
|
||||
/// device came back; the output is then opened again. The devices are looked
|
||||
/// at once a second.
|
||||
/// at once a second, on a thread of their own: a later tick reads what it
|
||||
/// found.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
@@ -1246,29 +1377,54 @@ impl Audio {
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.device_probe = None;
|
||||
self.default_seen = default_output_name();
|
||||
self.cards_seen = card_fingerprint();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
if let Some(pending) = &self.device_probe {
|
||||
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
|
||||
return false;
|
||||
};
|
||||
self.device_probe = None;
|
||||
return self.devices_changed(probe, current);
|
||||
}
|
||||
self.device_ticks += 1;
|
||||
if self._stream.is_none() || self.device_ticks < 20 {
|
||||
return false;
|
||||
}
|
||||
self.device_ticks = 0;
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them); only the
|
||||
// fingerprint's devices can change the list, and it is cheap.
|
||||
if default == self.default_seen
|
||||
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
|
||||
{
|
||||
return false;
|
||||
let pending = PendingProbe::default();
|
||||
let out = Arc::clone(&pending);
|
||||
let seen = (self.default_seen.clone(), self.cards_seen.clone());
|
||||
let started = std::thread::Builder::new()
|
||||
.name("audio-devices".into())
|
||||
.spawn(move || {
|
||||
let probe = probe_devices(seen.0, seen.1);
|
||||
if let Ok(mut found) = out.lock() {
|
||||
*found = Some(probe);
|
||||
}
|
||||
});
|
||||
match started {
|
||||
Ok(_) => self.device_probe = Some(pending),
|
||||
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The rest of `shouldChangeDevice` with what a look at the devices found,
|
||||
/// `current` being the device playing now.
|
||||
#[cfg(feature = "audio")]
|
||||
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
|
||||
let DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
} = probe;
|
||||
let Some(devices) = devices else {
|
||||
return false;
|
||||
};
|
||||
self.cards_seen = cards;
|
||||
let devices = output_devices();
|
||||
// Only a change in the device lists is looked at, as in vanilla: a
|
||||
// preferred device that is listed but cannot open is not retried.
|
||||
if default == self.default_seen && devices == self.devices_seen {
|
||||
@@ -1306,6 +1462,50 @@ impl Audio {
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
// A paused game holds a mod's muffle where it is, as it holds the sounds.
|
||||
if !paused && let Some(fade) = &mut self.muffle {
|
||||
fade.left = fade.left.saturating_sub(1);
|
||||
let level = fade.level();
|
||||
if fade.left == 0 {
|
||||
self.muffle = None;
|
||||
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
|
||||
}
|
||||
self.set_muffle_level(level);
|
||||
}
|
||||
}
|
||||
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
|
||||
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
|
||||
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
|
||||
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
|
||||
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
|
||||
/// player hears it. False for an amount or strength outside 0 to 1.
|
||||
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
|
||||
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
|
||||
return false;
|
||||
}
|
||||
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
|
||||
let exempt = if on {
|
||||
exempt
|
||||
.iter()
|
||||
.map(|e| crate::sound_events::normalize(e))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.engine.set_unmuffled(exempt, &mut self.backend);
|
||||
self.muffle = on.then_some(MuffleFade {
|
||||
amount,
|
||||
lowpass,
|
||||
ticks,
|
||||
left: ticks,
|
||||
});
|
||||
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
|
||||
true
|
||||
}
|
||||
fn set_muffle_level(&mut self, level: [f32; 2]) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.muffle.target = level;
|
||||
}
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
@@ -1418,7 +1618,7 @@ impl Audio {
|
||||
"minecraft:ui.button.click",
|
||||
1.0,
|
||||
0.25,
|
||||
SoundEngine::unseeded(),
|
||||
crate::random_source::create(),
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -1560,7 +1760,7 @@ impl Audio {
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
|
||||
let instance = SoundInstance::for_jukebox_song(&event, at, crate::random_source::create());
|
||||
self.play_instance(instance, true).0
|
||||
}
|
||||
/// Stop and drop a held instance.
|
||||
@@ -1771,6 +1971,7 @@ mod tests {
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2191,6 +2392,123 @@ mod tests {
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
|
||||
fn sine(hz: f32) -> Vec<f32> {
|
||||
(0..48_000)
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(data: &[f32]) -> f32 {
|
||||
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
|
||||
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
let mut buffer = [0.0; 960];
|
||||
for _ in 0..(seconds * 100.0).round() as usize {
|
||||
mix(m, &mut buffer, 2, 48_000);
|
||||
out.extend_from_slice(&buffer);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
|
||||
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
|
||||
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
|
||||
c.unmuffled = unmuffled;
|
||||
m.channels.insert(1, c);
|
||||
m.muffle.target = muffle;
|
||||
listen(&mut m, 0.2);
|
||||
listen(&mut m, 0.2)
|
||||
};
|
||||
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
|
||||
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
|
||||
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
|
||||
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
|
||||
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
|
||||
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
|
||||
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
|
||||
// An exempt sound plays through as if there were no muffle, sample for sample.
|
||||
assert_eq!(
|
||||
heard(6000.0, [0.5, 1.0], true),
|
||||
heard(6000.0, [0.0; 2], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faded_muffle_leaves_the_sound_as_it_was() {
|
||||
let mixer = || {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
|
||||
m
|
||||
};
|
||||
let (mut muffled, mut plain) = (mixer(), mixer());
|
||||
muffled.muffle.target = [0.7, 1.0];
|
||||
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
muffled.muffle.target = [0.0; 2];
|
||||
listen(&mut muffled, 0.5);
|
||||
listen(&mut plain, 0.5);
|
||||
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
audio.engine.registry.add_file(
|
||||
"hztest",
|
||||
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// The engine makes its channels; with no output, none loads a file.
|
||||
audio.engine.loaded = true;
|
||||
let marks = |audio: &Audio| {
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
let mut marks: Vec<_> = m
|
||||
.channels
|
||||
.iter()
|
||||
.map(|(&id, c)| (id, c.unmuffled))
|
||||
.collect();
|
||||
marks.sort();
|
||||
marks
|
||||
};
|
||||
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
// Two sounds already playing, and one exempt sound started under the muffle.
|
||||
audio.play("ambient.cave", None, 1.0, 1.0);
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
|
||||
assert!(close(level(&audio), [0.8, 1.0]));
|
||||
let ticks = TickContext::default();
|
||||
audio.tick(false, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
// A paused game holds it.
|
||||
audio.tick(true, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
for _ in 0..3 {
|
||||
audio.tick(false, &ticks);
|
||||
}
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
// Over: its exemptions go with it.
|
||||
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
|
||||
// Out of range changes nothing.
|
||||
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -77,6 +77,9 @@ pub struct BlockEntities {
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// State-aware native factory ownership. Shared across level snapshots;
|
||||
/// an empty slice means the declarations have not yet been compiled.
|
||||
stateful_entities: Arc<[bool]>,
|
||||
/// The energy buffers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
|
||||
@@ -209,6 +212,21 @@ impl BlockEntities {
|
||||
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
|
||||
self.mod_fluids.get(&state)
|
||||
}
|
||||
pub fn set_stateful_entities(&mut self, states: Vec<bool>) {
|
||||
self.stateful_entities = states.into();
|
||||
}
|
||||
pub fn has_stateful_entity_table(&self) -> bool {
|
||||
!self.stateful_entities.is_empty()
|
||||
}
|
||||
pub fn stateful_entities(&self) -> Arc<[bool]> {
|
||||
self.stateful_entities.clone()
|
||||
}
|
||||
pub fn stateful_entity(&self, state: u32) -> bool {
|
||||
self.stateful_entities
|
||||
.get(state as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
|
||||
self.mod_energy = energy;
|
||||
}
|
||||
@@ -538,6 +556,11 @@ impl BlockEntities {
|
||||
});
|
||||
Arc::clone(&self.tickers)
|
||||
}
|
||||
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
|
||||
/// the last [`Self::tickers`]: what a diagnostics mod counts.
|
||||
pub fn ticking_len(&self) -> usize {
|
||||
self.live.len()
|
||||
}
|
||||
/// Whether a slot from `tickers` still ticks.
|
||||
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
|
||||
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! decorated pots, conduits, enchanting-table and lectern books and copper
|
||||
//! golem statues, End portals and End gateways. Models are vanilla's baked `LayerDefinitions` layers
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
//! vanilla renderer. Faces carry their tint and world normal: the entity
|
||||
//! shader (and a shader pack) applies `minecraft_mix_light` from the normal, as
|
||||
//! vanilla's does.
|
||||
use crate::{
|
||||
Result,
|
||||
bidi::visual,
|
||||
@@ -128,6 +129,14 @@ fn pack_color(rgb: u32, alpha: u32, brightness: f32) -> u32 {
|
||||
let c = |shift: u32| (((rgb >> shift) & 255) as f32 * brightness) as u32;
|
||||
alpha << 24 | c(0) << 16 | c(8) << 8 | c(16)
|
||||
}
|
||||
/// A world normal as `Vertex::normal` holds it: RGBA8_SNORM, `w` positive so
|
||||
/// the entity shader and a shader pack both shade by it. Block-entity models
|
||||
/// carry no normals of their own (vanilla computes each face's from its
|
||||
/// polygon, then `minecraft_mix_light` uses it in the shader).
|
||||
fn pack_normal(normal: Vec3) -> [i8; 4] {
|
||||
let c = |v: f32| (v.clamp(-1.0, 1.0) * 127.0).round() as i8;
|
||||
[c(normal.x), c(normal.y), c(normal.z), 127]
|
||||
}
|
||||
|
||||
/// Everything a draw call needs besides the geometry.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -165,6 +174,7 @@ pub fn draw_part(
|
||||
light,
|
||||
lights,
|
||||
glow,
|
||||
..
|
||||
} = paint;
|
||||
for poly in &part.polygons {
|
||||
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
@@ -177,11 +187,21 @@ pub fn draw_part(
|
||||
} else {
|
||||
normal
|
||||
};
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
// The colour is the tint alone when the caller shades by the level's
|
||||
// lights: the entity shader applies `minecraft_mix_light` from the
|
||||
// normal, as vanilla does, so a shader pack receives the unshaded
|
||||
// colour Iris gives it (a baked shade would be applied a second time
|
||||
// by the pack's lighting). Other lights (an item preview's
|
||||
// `Lighting.Entry`) are not the shader's to know, so those stay baked
|
||||
// into the colour, as before.
|
||||
let baked = !(lights == diffuse_lights(false) || lights == diffuse_lights(true));
|
||||
let brightness = if baked { diffuse(normal, lights) } else { 1.0 };
|
||||
let color = pack_color(tint, 255, brightness);
|
||||
let normal = if baked { [0; 4] } else { pack_normal(normal) };
|
||||
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
normal,
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
@@ -653,15 +673,16 @@ fn sign_text_calls(
|
||||
let line_height = data.line_height();
|
||||
let midpoint = (4.0 * line_height / 2.0).floor();
|
||||
let dark = 0xFF00_0000 | text.dark_color();
|
||||
let (color, outline, light) = if text.glowing {
|
||||
let (color, outline, light, tier) = if text.glowing {
|
||||
let c = text.text_color();
|
||||
(
|
||||
0xFF00_0000 | c,
|
||||
if c == 0 || near { dark } else { 0 },
|
||||
240 | 240 << 8,
|
||||
crate::mesher::LOW,
|
||||
)
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed))
|
||||
(dark, 0, vertex_light(packed), 0)
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
|
||||
@@ -693,6 +714,7 @@ fn sign_text_calls(
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
}),
|
||||
}
|
||||
pen += span.text.chars().map(advance).sum::<f32>();
|
||||
@@ -707,6 +729,7 @@ fn sign_text_calls(
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -761,7 +784,7 @@ pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut V
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
@@ -830,6 +853,9 @@ pub struct TextCall {
|
||||
pub outline: u32,
|
||||
/// Horizon packed light (sky*16 | block*16 << 8).
|
||||
pub light: u32,
|
||||
/// HDR emission tier of its glyphs (`mesher::hdr_tier`), 0 for none: glowing sign text
|
||||
/// shines in HDR output and in shader packs (`horizonTier`), as full-bright text does in SDR.
|
||||
pub tier: u8,
|
||||
}
|
||||
|
||||
/// Inputs from the game for one frame of block-entity rendering.
|
||||
@@ -3774,6 +3800,105 @@ mod tests {
|
||||
// Straight down: ambient only.
|
||||
assert!((diffuse(-Vec3::Y, lights) - 0.4).abs() < 1e-6);
|
||||
}
|
||||
/// A face's colour carries its tint alone and its normal carries the
|
||||
/// shading: the entity shader applies `minecraft_mix_light` from it, as a
|
||||
/// shader pack's lighting does, so a baked shade would be applied twice.
|
||||
#[test]
|
||||
fn a_face_keeps_its_tint_and_normal_for_the_shader_to_shade() {
|
||||
let quad = |y: f32, z0: f32, z1: f32| {
|
||||
[
|
||||
[0.0, y, z0, 0.0, 0.0],
|
||||
[0.0, y, z1, 0.0, 1.0],
|
||||
[16.0, y, z1, 1.0, 1.0],
|
||||
[16.0, y, z0, 1.0, 0.0],
|
||||
]
|
||||
};
|
||||
// Opposite windings: the first faces up, the second down.
|
||||
let part = ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![quad(0.0, 0.0, 16.0), quad(16.0, 16.0, 0.0)],
|
||||
children: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_part(
|
||||
&part,
|
||||
"root",
|
||||
Mat4::IDENTITY,
|
||||
&|_, _| {},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0x123456,
|
||||
light: 0,
|
||||
lights: diffuse_lights(false),
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 8);
|
||||
// The same tint on both faces: no directional shade baked in (`pack_color`
|
||||
// stores the tint in the vertex colour's BGR byte order).
|
||||
let tint = pack_color(0x12_3456, 255, 1.0);
|
||||
assert!(out.iter().all(|v| v.color == tint));
|
||||
let (up, down) = (&out[0], &out[4]);
|
||||
assert_eq!(up.normal, [0, 127, 0, 127]);
|
||||
assert_eq!(down.normal, [0, -127, 0, 127]);
|
||||
// The shader still gets the difference the old baked colours held.
|
||||
let lights = diffuse_lights(false);
|
||||
let up_normal = Vec3::new(0.0, f32::from(up.normal[1]) / 127.0, 0.0);
|
||||
let down_normal = Vec3::new(0.0, f32::from(down.normal[1]) / 127.0, 0.0);
|
||||
assert!(diffuse(up_normal, lights) > diffuse(down_normal, lights));
|
||||
}
|
||||
/// Lights the entity shader does not know (an item preview's
|
||||
/// `Lighting.Entry`) stay baked into the colour, and `normal.w` 0 tells
|
||||
/// the shader to keep it: `game/item_draw` passes those for block-entity
|
||||
/// items in inventories and hands.
|
||||
#[test]
|
||||
fn lights_other_than_the_levels_stay_baked() {
|
||||
let part = ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 16.0, 0.0, 1.0],
|
||||
[16.0, 0.0, 16.0, 1.0, 1.0],
|
||||
[16.0, 0.0, 0.0, 1.0, 0.0],
|
||||
]],
|
||||
children: vec![],
|
||||
};
|
||||
let lights = (Vec3::X, Vec3::Z);
|
||||
let mut out = Vec::new();
|
||||
draw_part(
|
||||
&part,
|
||||
"root",
|
||||
Mat4::IDENTITY,
|
||||
&|_, _| {},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0x12_3456,
|
||||
light: 0,
|
||||
lights,
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
let baked = pack_color(0x12_3456, 255, diffuse(Vec3::Y, lights));
|
||||
assert!(out.iter().all(|v| v.color == baked));
|
||||
assert!(out.iter().all(|v| v.normal == [0; 4]));
|
||||
assert_ne!(baked, pack_color(0x12_3456, 255, 1.0));
|
||||
}
|
||||
#[test]
|
||||
fn part_poses_compose_like_model_part() {
|
||||
let part = ModelPart {
|
||||
@@ -3917,6 +4042,7 @@ mod tests {
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
@@ -3990,6 +4116,7 @@ mod tests {
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
tier: 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
|
||||
@@ -993,7 +993,9 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
scene.nether = true;
|
||||
let nether_closed = render_cached(&scene);
|
||||
assert_same_vertices(&nether_closed, &render_direct(&renderer, &scene, &blocks));
|
||||
assert_vertices_differ(&closed_again, &nether_closed);
|
||||
// The nether is the entity shader's flag now (`entity_light`'s `l1` and
|
||||
// `globals.time.y`), not a baked colour, so the cached geometry serves both.
|
||||
assert_same_vertices(&closed_again, &nether_closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -114,12 +114,8 @@ mod tests {
|
||||
ambient_occlusion: true,
|
||||
particle: None,
|
||||
cube: None,
|
||||
faces: vec![BakedFace {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
quad: ModelQuad {
|
||||
faces: vec![BakedFace::new(
|
||||
ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 1.0],
|
||||
@@ -142,7 +138,11 @@ mod tests {
|
||||
render_type: None,
|
||||
shade: true,
|
||||
},
|
||||
}],
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
)],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -387,8 +387,21 @@ impl BrewingStand {
|
||||
out.brewed = true;
|
||||
}
|
||||
}
|
||||
/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier).
|
||||
/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier),
|
||||
/// respecting component patches before falling back to the item's defaults.
|
||||
pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> {
|
||||
if let Some(data) = stack.data() {
|
||||
if data.other("!minecraft:brewing_fuel").is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(own) = data.other("minecraft:brewing_fuel") {
|
||||
let component = horizon_client_core::scoreboard::tag_to_json(own);
|
||||
return Some((
|
||||
component["uses"].clone(),
|
||||
component["speed_multiplier"].clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
items.get(&stack.item).and_then(|i| i.brewing_fuel.clone())
|
||||
}
|
||||
|
||||
@@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stack_fuel_controls_brew_duration_and_consumption() {
|
||||
let items = Items::for_tests(Default::default());
|
||||
let brewing = Brewing {
|
||||
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
inputs: HashSet::new(),
|
||||
recipes: vec![BrewingRecipe {
|
||||
id: "awkward".into(),
|
||||
input: PotionIngredient {
|
||||
items: ["minecraft:potion".to_owned()].into_iter().collect(),
|
||||
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
|
||||
},
|
||||
reagent: PotionIngredient {
|
||||
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
potions: None,
|
||||
},
|
||||
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
|
||||
}],
|
||||
};
|
||||
let mut fuel = ItemStack::new("minecraft:stone", 2);
|
||||
fuel.data_mut().set_loot_component(
|
||||
"minecraft:brewing_fuel",
|
||||
&serde_json::json!({"uses": 7, "speed_multiplier": 2.5}),
|
||||
);
|
||||
let mut stand = BrewingStand::new();
|
||||
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
|
||||
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
stand.items[4] = Some(fuel);
|
||||
let providers = Providers::empty();
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7));
|
||||
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
|
||||
for _ in 1..160 {
|
||||
assert!(!stand.tick(&brewing, &items, &providers).brewed);
|
||||
}
|
||||
assert!(stand.tick(&brewing, &items, &providers).brewed);
|
||||
assert_eq!(
|
||||
stand.items[0].as_ref().unwrap().potion(),
|
||||
Some("minecraft:awkward")
|
||||
);
|
||||
assert_eq!(stand.fuel, 6);
|
||||
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn potion_colours_and_names() {
|
||||
// Swiftness: speed colour 3402751 = 0x33EBFF.
|
||||
|
||||
@@ -1188,11 +1188,27 @@ impl SuggestLayout {
|
||||
};
|
||||
}
|
||||
|
||||
/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SuggestionCompletion {
|
||||
/// The suggestion list's original input, also when cycling with Tab.
|
||||
pub original: String,
|
||||
/// The replaced range in `original`, as Java UTF-16 start/end offsets.
|
||||
pub range: (usize, usize),
|
||||
pub text: String,
|
||||
/// The resulting input, after the text field's length limit.
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
pub struct ChatScreen {
|
||||
pub input: TextField,
|
||||
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
|
||||
/// has not been edited since. A draft draws grey italic, and the first
|
||||
/// Backspace clears the whole box instead of one character.
|
||||
is_draft: bool,
|
||||
width: i32,
|
||||
height: i32,
|
||||
history_buffer: String,
|
||||
@@ -1207,6 +1223,7 @@ pub struct ChatScreen {
|
||||
/// the command up to the cursor.
|
||||
request: Option<(i32, String)>,
|
||||
suggestions: Option<SuggestionList>,
|
||||
completion: Option<SuggestionCompletion>,
|
||||
usage: Vec<Text>,
|
||||
usage_position: i32,
|
||||
usage_width: i32,
|
||||
@@ -1300,11 +1317,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
|
||||
s.to_owned()
|
||||
}
|
||||
impl ChatScreen {
|
||||
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
|
||||
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
|
||||
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
|
||||
pub fn new(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
initial: &str,
|
||||
is_draft: bool,
|
||||
history_len: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
@@ -1317,6 +1336,7 @@ impl ChatScreen {
|
||||
input.set_value(m, initial);
|
||||
let mut s = Self {
|
||||
input,
|
||||
is_draft,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
@@ -1326,6 +1346,7 @@ impl ChatScreen {
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
completion: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
@@ -1353,6 +1374,7 @@ impl ChatScreen {
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
is_draft: false,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
@@ -1362,6 +1384,7 @@ impl ChatScreen {
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
completion: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
@@ -1377,12 +1400,18 @@ impl ChatScreen {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.input.value
|
||||
}
|
||||
/// Take the most recently accepted suggestion, at most once. Call after
|
||||
/// each key or click to observe every acceptance, including Tab cycling.
|
||||
pub fn take_suggestion_completion(&mut self) -> Option<SuggestionCompletion> {
|
||||
self.completion.take()
|
||||
}
|
||||
/// Replace the input for a suggest-command link and refresh completions.
|
||||
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
|
||||
self.input.set_value(m, command);
|
||||
self.edited(m, c);
|
||||
}
|
||||
/// `ChatScreen.resize`: a new screen with the current text.
|
||||
/// `ChatScreen.resize`: a new screen with the current text, still a draft
|
||||
/// while it has not been edited (`ChatScreen.resize` keeps the field).
|
||||
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
|
||||
if (width, height) != (self.width, self.height) {
|
||||
let value = self.input.value.clone();
|
||||
@@ -1390,7 +1419,16 @@ impl ChatScreen {
|
||||
let abilities = self.abilities;
|
||||
let message = std::mem::take(&mut self.input.message);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
Self::new(
|
||||
m,
|
||||
c,
|
||||
&value,
|
||||
self.is_draft,
|
||||
self.history_pos,
|
||||
width,
|
||||
height,
|
||||
abilities,
|
||||
)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
@@ -1402,6 +1440,7 @@ impl ChatScreen {
|
||||
fn edited(&mut self, m: &Measure, c: &Commands) {
|
||||
self.allow_suggestions = true;
|
||||
self.update_command_info(m, c);
|
||||
self.is_draft = false;
|
||||
}
|
||||
/// Ctrl+A/C/V/X in the input box.
|
||||
pub fn shortcut(
|
||||
@@ -1415,6 +1454,24 @@ impl ChatScreen {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Edit the existing input and refresh its command responder when it changes.
|
||||
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
|
||||
let before = (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
);
|
||||
f(&mut self.input);
|
||||
if before
|
||||
!= (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
)
|
||||
{
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Typed text (`charTyped` per character).
|
||||
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
|
||||
for ch in text.chars() {
|
||||
@@ -1714,6 +1771,17 @@ impl ChatScreen {
|
||||
self.select(l, l.current as isize);
|
||||
self.keep_suggestions = false;
|
||||
l.tab_cycles = true;
|
||||
let units = |byte: usize| {
|
||||
l.original[..byte.min(l.original.len())]
|
||||
.encode_utf16()
|
||||
.count()
|
||||
};
|
||||
self.completion = Some(SuggestionCompletion {
|
||||
original: l.original.clone(),
|
||||
range: (units(s.range.0), units(s.range.1)),
|
||||
text: s.text,
|
||||
input: self.input.value.clone(),
|
||||
});
|
||||
}
|
||||
/// `ChatScreen.keyPressed`.
|
||||
pub fn key(
|
||||
@@ -1729,6 +1797,14 @@ impl ChatScreen {
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
// ChatScreen.keyPressed: while the box holds an unedited draft, the
|
||||
// first Backspace clears all of it rather than one character.
|
||||
if self.is_draft && key == K::Backspace {
|
||||
self.input.set_value(m, "");
|
||||
self.is_draft = false;
|
||||
self.edited(m, c);
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
@@ -1862,8 +1938,13 @@ impl ChatScreen {
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input.
|
||||
/// `CommandSuggestions.formatText`: argument colours over the input. The
|
||||
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
|
||||
/// and wins while the draft is unedited: the whole text is grey italic.
|
||||
fn format(&self, value: &str, offset: usize) -> Text {
|
||||
if self.is_draft {
|
||||
return Text::literal(value).with_color(text::GRAY).italic();
|
||||
}
|
||||
let gray = Style::color(text::GRAY);
|
||||
let Some(parse) = &self.parse else {
|
||||
return Text::literal(value);
|
||||
@@ -1990,6 +2071,11 @@ impl ChatScreen {
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
// A row for the pad: nothing else can pick a suggestion without Tab.
|
||||
crate::widgets::target_as(
|
||||
[rx as f32, y as f32, rw as f32, 12.0],
|
||||
crate::widgets::Kind::Row,
|
||||
);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -7,6 +7,85 @@ fn full() -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::Full)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries {
|
||||
players: vec!["Steve".into(), "Steven".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "😀 hi st");
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
screen.key(&m, &c, &[], K::Tab, false, false);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"opening is not accepting"
|
||||
);
|
||||
for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] {
|
||||
screen.key(&m, &c, &[], K::Tab, shift, false);
|
||||
assert_eq!(screen.value(), format!("😀 hi {text}"));
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "😀 hi st".into(),
|
||||
range: (6, 8),
|
||||
text: text.into(),
|
||||
input: format!("😀 hi {text}"),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"reported once"
|
||||
);
|
||||
}
|
||||
screen.key(&m, &c, &[], K::ArrowDown, false, false);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"selection is not accepting"
|
||||
);
|
||||
screen.key(&m, &c, &[], K::Escape, false, false);
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_suggestion_reports_the_actual_length_limited_input() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.input.max_length = 6;
|
||||
screen.type_text(&m, &c, "hi st");
|
||||
screen.key(&m, &c, &[], K::Tab, false, false);
|
||||
let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect;
|
||||
assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1)));
|
||||
assert_eq!(screen.value(), "hi Ste");
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "hi st".into(),
|
||||
range: (3, 5),
|
||||
text: "Steve".into(),
|
||||
input: "hi Ste".into(),
|
||||
})
|
||||
);
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
}
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
@@ -201,7 +280,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
|
||||
assert_eq!(link.component()["click_event"]["command"], expected);
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, Text::literal("Found at ").append(link));
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
|
||||
let x = 4.0 + f64::from(font.width("Found at "));
|
||||
assert!(!screen.click(&m, &commands, x, 315.0));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
@@ -242,6 +321,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
&m,
|
||||
&commands,
|
||||
"unfinished draft",
|
||||
false,
|
||||
history.len(),
|
||||
640,
|
||||
360,
|
||||
@@ -263,6 +343,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
|
||||
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
|
||||
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
|
||||
/// and the first Backspace clears the whole draft instead of one character.
|
||||
/// Editing the draft (`onEdited`) ends both.
|
||||
#[test]
|
||||
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
|
||||
use winit::keyboard::KeyCode::Backspace;
|
||||
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
// The input's own text as the game draws it: x 4, y height - 12.
|
||||
let input_text = |screen: &mut ChatScreen| -> Text {
|
||||
screen
|
||||
.draw(&m, (0, 0), 0, i32::MIN)
|
||||
.into_iter()
|
||||
.find_map(|draw| match draw {
|
||||
Draw::Text {
|
||||
text, x: 4, y: 348, ..
|
||||
} => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the input draws its text")
|
||||
};
|
||||
let draft_style = |t: &Text| {
|
||||
t.spans
|
||||
.iter()
|
||||
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
|
||||
assert_eq!(screen.value(), "half-written");
|
||||
assert!(
|
||||
draft_style(&input_text(&mut screen)),
|
||||
"the restored draft draws grey italic"
|
||||
);
|
||||
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
|
||||
// eats one character again.
|
||||
screen.type_text(&m, &c, "!");
|
||||
assert_eq!(screen.value(), "half-written!");
|
||||
let typed = input_text(&mut screen);
|
||||
assert!(
|
||||
!typed.spans.iter().any(|s| s.style.is_italic()),
|
||||
"an edited draft is not grey italic"
|
||||
);
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "half-written");
|
||||
// A draft left alone clears whole on its first Backspace.
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
|
||||
// The flag is gone with it: typing is ordinary text, and Backspace eats
|
||||
// one character.
|
||||
screen.type_text(&m, &c, "hi");
|
||||
assert_eq!(screen.value(), "hi");
|
||||
let typed = input_text(&mut screen);
|
||||
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "h");
|
||||
// A box opened on ordinary text deletes one character at a time.
|
||||
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
|
||||
assert!(!draft_style(&input_text(&mut screen)));
|
||||
assert_eq!(
|
||||
screen.key(&m, &c, &[], Backspace, false, false),
|
||||
ChatAction::None
|
||||
);
|
||||
assert_eq!(screen.value(), "half-writte");
|
||||
// A command draft is grey italic as a whole: `formatChat` is registered
|
||||
// before `CommandSuggestions.formatText`, so it wins over the argument
|
||||
// colours.
|
||||
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
|
||||
assert!(draft_style(&input_text(&mut screen)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
let font = font();
|
||||
@@ -283,7 +450,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
dispatcher,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
screen
|
||||
};
|
||||
@@ -333,7 +500,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
@@ -381,7 +548,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
let tab = |screen: &mut ChatScreen| {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
let (request, sent) = screen.take_request().expect("the server is asked");
|
||||
assert_eq!(sent, "/bossbar get pt");
|
||||
@@ -395,15 +562,25 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt");
|
||||
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt6:check");
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "/bossbar get pt".into(),
|
||||
range: (13, 15),
|
||||
text: "pt6:check".into(),
|
||||
input: "/bossbar get pt6:check".into(),
|
||||
})
|
||||
);
|
||||
// Without the server's provider the local list is done at once.
|
||||
let local = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher: local,
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
@@ -473,7 +650,7 @@ fn the_chat_option_decides_what_may_be_sent() {
|
||||
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
|
||||
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
|
||||
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
|
||||
screen.type_text(&m, &c, line);
|
||||
match enter(&mut screen) {
|
||||
ChatAction::Submit(sent) => {
|
||||
@@ -499,7 +676,16 @@ fn restricted_input_says_why_it_will_not_send() {
|
||||
registries: ®istries,
|
||||
};
|
||||
let usage = |visibility, line: &str| {
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&c,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
640,
|
||||
360,
|
||||
ChatAbilities::new(visibility),
|
||||
);
|
||||
screen.type_text(&m, &c, line);
|
||||
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
@@ -620,6 +620,14 @@ impl Chunk {
|
||||
entities: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The original `structures.starts`, including starts with unknown pieces.
|
||||
pub fn saved_structure_starts(&self) -> Option<&horizon_world::nbt::Compound> {
|
||||
let structures = self.saved.as_ref()?.root.get(&"structures".into())?;
|
||||
match structures.get("starts")? {
|
||||
horizon_world::nbt::Tag::Compound(starts) => Some(starts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The minimum block Y of the column.
|
||||
#[inline]
|
||||
pub fn min_y(&self) -> i32 {
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_world::{
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Mutex, OnceLock, PoisonError,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
@@ -59,6 +59,9 @@ pub enum BlockEntity {
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CopperGolemStatueBlockEntity`: its own components, which hold the
|
||||
/// custom name of the golem it was (`createStatue`).
|
||||
CopperGolemStatue(Compound),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
@@ -108,6 +111,9 @@ pub enum BlockEntity {
|
||||
pub struct ModBlockEntity {
|
||||
fields: ModBlockEntityFields,
|
||||
revision: ModRevision,
|
||||
// A client update can consume transient effects without changing saved
|
||||
// fields. Most entities never use this; do not allocate their queue.
|
||||
client_update_fields: OnceLock<Mutex<Compound>>,
|
||||
}
|
||||
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
@@ -138,11 +144,55 @@ impl ModBlockEntity {
|
||||
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
|
||||
changes: 0,
|
||||
},
|
||||
client_update_fields: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
pub fn revision(&self) -> ModRevision {
|
||||
self.revision
|
||||
}
|
||||
/// Replace the owner's pending, one-shot client update fields. These
|
||||
/// fields are not saved, cloned, compared or retained for drawing, and
|
||||
/// changing them does not change this entity's revision. The owner must
|
||||
/// explicitly take and merge them into its update tag. Calling this does
|
||||
/// not schedule a block update.
|
||||
///
|
||||
/// An empty batch clears a pending batch without allocating an idle queue.
|
||||
pub fn set_client_update_fields(&mut self, fields: Compound) {
|
||||
if fields.is_empty() {
|
||||
self.clear_client_update_fields();
|
||||
return;
|
||||
}
|
||||
*self
|
||||
.client_update_fields
|
||||
.get_or_init(|| Mutex::new(Compound::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = fields;
|
||||
}
|
||||
/// Discard the owner's pending client update fields, without changing
|
||||
/// saved data, revision or a queue that has never been used.
|
||||
pub fn clear_client_update_fields(&mut self) {
|
||||
if let Some(fields) = self.client_update_fields.get() {
|
||||
fields
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clear();
|
||||
}
|
||||
}
|
||||
/// Atomically take the owner's pending fields at update-tag construction.
|
||||
/// A loaded chunk and a block change use this same boundary. Construct a
|
||||
/// tag once, then send that tag to every recipient; another construction
|
||||
/// sees an empty batch. A save or a clone does not consume this batch.
|
||||
///
|
||||
/// This is safe for an update-tag hook receiving only a shared reference,
|
||||
/// including a saved chunk encoded by a worker thread. If a previous lock
|
||||
/// holder panicked, preserve its pending data instead of dropping it.
|
||||
pub fn take_client_update_fields(&self) -> Compound {
|
||||
self.client_update_fields
|
||||
.get()
|
||||
.map_or_else(Compound::new, |fields| {
|
||||
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
})
|
||||
}
|
||||
/// A client's copy loads the tag the server sent (`loadWithComponents`
|
||||
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
|
||||
/// replace its own of the same names. One it already has as sent is
|
||||
@@ -706,6 +756,85 @@ pub struct CommandBlock {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "test:machine".into(),
|
||||
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
|
||||
..Default::default()
|
||||
});
|
||||
let revision = machine.revision();
|
||||
let saved = machine.fields.clone();
|
||||
let debug = format!("{machine:?}");
|
||||
machine.clear_client_update_fields();
|
||||
machine.set_client_update_fields(Compound::new());
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert!(machine.client_update_fields.get().is_none());
|
||||
|
||||
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
|
||||
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let copy = machine.clone();
|
||||
assert_eq!(machine, copy, "transient fields are not retained contents");
|
||||
assert_ne!(machine.revision(), copy.revision());
|
||||
assert!(copy.client_update_fields.get().is_none());
|
||||
assert!(copy.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
machine.set_client_update_fields(expected);
|
||||
machine.clear_client_update_fields();
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.fields, saved);
|
||||
assert_eq!(machine.revision(), revision);
|
||||
assert_eq!(format!("{machine:?}"), debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_client_update_constructions_consume_one_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let received = std::thread::scope(|scope| {
|
||||
let handles = (0..16)
|
||||
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let nonempty = received
|
||||
.into_iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(nonempty, [expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
std::thread::scope(|scope| {
|
||||
assert!(
|
||||
scope
|
||||
.spawn(|| {
|
||||
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
|
||||
panic!("interrupt before consuming the queued batch");
|
||||
})
|
||||
.join()
|
||||
.is_err()
|
||||
);
|
||||
});
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
|
||||
@@ -208,7 +208,7 @@ where
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
|
||||
}
|
||||
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
|
||||
/// `EntityGetter.getEntityCollisions` gave the moving entity.
|
||||
pub fn resolve_in_border_with(
|
||||
self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
entities: &[Aabb],
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
entities,
|
||||
border,
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
@@ -266,7 +279,7 @@ impl SceneCollisions<'_, '_> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
@@ -287,6 +300,7 @@ impl SceneCollisions<'_, '_> {
|
||||
CollisionContext::for_entity(
|
||||
CollisionEntity {
|
||||
identity: EntityIdentity(0),
|
||||
kind: horizon_world::collision_context::EntityContextKind::Living,
|
||||
y,
|
||||
descending: false,
|
||||
main_hand: Some(CollisionItem::EMPTY),
|
||||
@@ -322,6 +336,26 @@ where
|
||||
{
|
||||
no_collision_in(world, bounds, true)
|
||||
}
|
||||
/// `CollisionGetter.noCollision(entity, bounds)`: retain the real entity snapshot.
|
||||
pub fn no_collision_with_context<W, E>(
|
||||
world: &mut W,
|
||||
bounds: Aabb,
|
||||
context: CollisionContext<'_>,
|
||||
) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
{
|
||||
no_world_collision(
|
||||
world,
|
||||
CollisionCheckRequest {
|
||||
entity: context.entity().unwrap_or(EntityIdentity(0)),
|
||||
context,
|
||||
bounds,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
|
||||
@@ -395,6 +395,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name.ends_with("copper_golem_statue") {
|
||||
return Some(Self::CopperGolemStatue(Default::default()));
|
||||
}
|
||||
if name == "minecraft:beacon" {
|
||||
return Some(Self::Beacon(Box::new(Beacon {
|
||||
last_check_y: i32::MIN / 2,
|
||||
@@ -460,6 +463,7 @@ impl BlockEntity {
|
||||
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
|
||||
Self::Skull(_) => is_skull(name),
|
||||
Self::Banner(_) => name.ends_with("_banner"),
|
||||
Self::CopperGolemStatue(_) => name.ends_with("copper_golem_statue"),
|
||||
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
|
||||
Self::Mod(_) => !name.starts_with("minecraft:"),
|
||||
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
|
||||
@@ -510,6 +514,7 @@ impl BlockEntity {
|
||||
Self::Lectern(_) => "minecraft:lectern",
|
||||
Self::Sculk(s) => s.id(),
|
||||
Self::Banner(_) => "minecraft:banner",
|
||||
Self::CopperGolemStatue(_) => "minecraft:copper_golem_statue",
|
||||
Self::Crafter(_) => "minecraft:crafter",
|
||||
Self::CreakingHeart(_) => "minecraft:creaking_heart",
|
||||
Self::Spawner(_) => "minecraft:mob_spawner",
|
||||
@@ -558,7 +563,7 @@ impl BlockEntity {
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Banner(_) | Self::CopperGolemStatue(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
@@ -575,6 +580,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::CopperGolemStatue(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
@@ -609,6 +615,7 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::CopperGolemStatue(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
|
||||
@@ -65,6 +65,44 @@ pub struct Setting {
|
||||
pub item: Item,
|
||||
pub text: &'static str,
|
||||
}
|
||||
/// What the chosen value does, for a setting whose values each do something different.
|
||||
fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
Some(match opt {
|
||||
Opt::PadGameplayGuide => match pad.guide_detail {
|
||||
gamepad::GuideDetail::Full => {
|
||||
"Shows what your buttons do right now, in play and in inventories."
|
||||
}
|
||||
gamepad::GuideDetail::Basic => {
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons."
|
||||
}
|
||||
gamepad::GuideDetail::Off => "Doesn't show what your buttons do.",
|
||||
},
|
||||
Opt::PadGlyphs => match pad.glyphs {
|
||||
GlyphStyle::Auto => "Shows the buttons of the controller you're using.",
|
||||
GlyphStyle::Xbox => "Always shows Xbox buttons.",
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
}
|
||||
gamepad::TextKeyboard::Horizon => "Always opens Horizon's keyboard.",
|
||||
gamepad::TextKeyboard::Steam => "Always opens Steam's keyboard.",
|
||||
},
|
||||
Opt::PadLightbar => match pad.lightbar {
|
||||
gamepad::Lightbar::Health => {
|
||||
"A DualSense's light shows your health, from green through amber to red."
|
||||
}
|
||||
gamepad::Lightbar::Dimension => {
|
||||
"A DualSense's light changes with the dimension you're in."
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn option(opt: Opt, text: &'static str) -> Setting {
|
||||
Setting {
|
||||
item: Item::Option(opt),
|
||||
@@ -97,10 +135,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
vec![
|
||||
(
|
||||
"Controller",
|
||||
vec![option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
),
|
||||
option(
|
||||
Opt::PadPlayInBackground,
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Look",
|
||||
@@ -135,10 +179,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
(
|
||||
"Play",
|
||||
vec![option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadGameplayGuide,
|
||||
"Show the buttons for what you can do, while you play with a controller.",
|
||||
),
|
||||
option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
@@ -279,6 +329,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Option(Opt::PadEnabled) => Some(c.enabled),
|
||||
Item::Option(Opt::PadVibration) => Some(c.vibration),
|
||||
Item::Option(Opt::PadPlayInBackground) => Some(c.play_in_background),
|
||||
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
|
||||
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
|
||||
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
|
||||
@@ -604,6 +655,7 @@ impl ControllerUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
}
|
||||
@@ -818,8 +870,12 @@ impl ControllerUi {
|
||||
/// What a row does. A binding names the controller's own buttons, and what else its button
|
||||
/// does when it shares it.
|
||||
fn description(&self, setting: Setting, s: &Settings) -> String {
|
||||
let Item::Bind(action) = setting.item else {
|
||||
return lang::get(setting.text);
|
||||
let action = match setting.item {
|
||||
Item::Bind(action) => action,
|
||||
Item::Option(opt) => {
|
||||
return lang::get(choice_description(opt, &s.controller).unwrap_or(setting.text));
|
||||
}
|
||||
_ => return lang::get(setting.text),
|
||||
};
|
||||
let pad = &s.controller;
|
||||
let style = self.context.style;
|
||||
@@ -1265,6 +1321,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadEnabled,
|
||||
Opt::PadVibration,
|
||||
Opt::PadPlayInBackground,
|
||||
Opt::PadAdaptiveTriggers,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadLookSensitivity,
|
||||
@@ -1372,6 +1429,30 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
fn each_choice_describes_itself() {
|
||||
let ui = ControllerUi::default();
|
||||
let mut s = Settings::default();
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..choices {
|
||||
let text = ui.description(setting, &s);
|
||||
assert_ne!(text, "the fixed sentence", "{opt:?}");
|
||||
assert!(!seen.contains(&text), "{opt:?} repeats {text}");
|
||||
seen.push(text);
|
||||
opt.cycle(&mut s, false, FB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
@@ -1385,6 +1466,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
opt.cycle(&mut settings, false, FB);
|
||||
}
|
||||
}
|
||||
for text in [
|
||||
"Controller",
|
||||
"Player profile",
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use horizon_telemetry::system::machine_arch;
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The build's architecture, and the machine's after "on" when it emulates the build.
|
||||
fn arch_label(build: &str, machine: &str) -> String {
|
||||
if build == machine {
|
||||
build.to_owned()
|
||||
} else {
|
||||
format!("{build} on {machine}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
let arch = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
let version = if cfg!(target_os = "linux") {
|
||||
let name = read("/etc/os-release").and_then(|t| {
|
||||
t.lines()
|
||||
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_emulated_build_names_both_architectures() {
|
||||
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
|
||||
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
|
||||
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
|
||||
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
|
||||
// On the machine running the tests: native builds name one architecture.
|
||||
let label = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
assert!(label.starts_with(std::env::consts::ARCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Draw {
|
||||
pub image: u16,
|
||||
pub blend: Blend,
|
||||
pub brightness: f32,
|
||||
/// Camera-relative world from the skybox's space.
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,8 @@ impl DataPack {
|
||||
pub fn discover(world: &Path) -> Vec<DataPack> {
|
||||
discover_folder(&world.join("datapacks"))
|
||||
}
|
||||
/// Packs in Create World's temporary pack folder, before a world exists.
|
||||
/// `FolderRepositorySource.loadPacks`: packs in Create World's temporary
|
||||
/// folder or a world's data-pack directory, with required folder metadata.
|
||||
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
@@ -150,7 +151,7 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let path = e.path();
|
||||
if path.is_dir() && path.join("pack.mcmeta").exists() {
|
||||
if path.is_dir() && folder_metadata_present(&path) {
|
||||
packs.push(DataPack {
|
||||
id: format!("file/{name}"),
|
||||
source: Source::Dir(path),
|
||||
@@ -168,6 +169,22 @@ pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
|
||||
packs.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
packs
|
||||
}
|
||||
/// `Pack.readPackMetadata`: even its fallback codec requires a pack section
|
||||
/// and description. Keep this folder gate separate from format compatibility
|
||||
/// and the existing interpretation of the description component.
|
||||
fn folder_metadata_present(path: &Path) -> bool {
|
||||
let Some(meta) = std::fs::read(path.join("pack.mcmeta"))
|
||||
.ok()
|
||||
.and_then(|bytes| crate::packs::parse_meta(&bytes))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
meta.as_object()
|
||||
.and_then(|root| root.get("pack"))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|pack| pack.get("description"))
|
||||
.is_some_and(|description| !description.is_null())
|
||||
}
|
||||
/// The resources root's built-in feature packs (`ServerPacksSource`
|
||||
/// scanning the game's `data/minecraft/datapacks`): every folder with a
|
||||
/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
|
||||
@@ -608,7 +625,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -617,7 +634,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -625,7 +642,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
|
||||
| "villager_trade" | "dialog" | "damage_type" | "data_maps" | "enchantment" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -659,6 +676,7 @@ impl DataStore {
|
||||
"damage_type",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"enchantment",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
@@ -714,15 +732,55 @@ impl DataStore {
|
||||
}
|
||||
s
|
||||
}
|
||||
/// `FunctionManager.getTag`: the functions a tag expands to (nested tags
|
||||
/// included, each function once).
|
||||
/// `ServerFunctionLibrary.getTag`: only valid function tags expand
|
||||
/// (nested tags included, each function once).
|
||||
pub fn tag_functions(&self, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen_fns = std::collections::HashSet::new();
|
||||
let mut seen_tags = std::collections::HashSet::new();
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags);
|
||||
let invalid = self.invalid_function_tags(id);
|
||||
self.expand(id, &mut out, &mut seen_fns, &mut seen_tags, &invalid);
|
||||
out
|
||||
}
|
||||
/// `TagLoader.tryBuildTag`: missing required members invalidate their tag and
|
||||
/// required parents. Optional invalid children contribute nothing.
|
||||
/// Resolve against the current accepted function map, without caching a
|
||||
/// candidate store or changing the existing expansion's cycle handling.
|
||||
fn invalid_function_tags<'a>(&'a self, id: &'a str) -> std::collections::HashSet<&'a str> {
|
||||
let mut invalid = std::collections::HashSet::new();
|
||||
let mut parents: std::collections::HashMap<&str, Vec<&str>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut pending = vec![id];
|
||||
while let Some(tag) = pending.pop() {
|
||||
if !seen.insert(tag) {
|
||||
continue;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(tag) else {
|
||||
invalid.insert(tag);
|
||||
continue;
|
||||
};
|
||||
for (entry, required) in entries {
|
||||
if let Some(child) = entry.strip_prefix('#') {
|
||||
pending.push(child);
|
||||
if *required {
|
||||
parents.entry(child).or_default().push(tag);
|
||||
}
|
||||
} else if *required && !self.functions.contains_key(entry) {
|
||||
invalid.insert(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending.extend(invalid.iter().copied());
|
||||
while let Some(child) = pending.pop() {
|
||||
for &parent in parents.get(child).into_iter().flatten() {
|
||||
if invalid.insert(parent) {
|
||||
pending.push(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
invalid
|
||||
}
|
||||
/// Depth-first walk keeping first-seen order. Each tag is expanded at most
|
||||
/// once, so reference cycles end and nesting depth is not capped.
|
||||
fn expand<'a>(
|
||||
@@ -731,18 +789,19 @@ impl DataStore {
|
||||
out: &mut Vec<String>,
|
||||
seen_fns: &mut std::collections::HashSet<&'a str>,
|
||||
seen_tags: &mut std::collections::HashSet<&'a str>,
|
||||
invalid: &std::collections::HashSet<&'a str>,
|
||||
) {
|
||||
if !seen_tags.insert(id) {
|
||||
if invalid.contains(id) || !seen_tags.insert(id) {
|
||||
return;
|
||||
}
|
||||
let Some(entries) = self.function_tags.get(id) else {
|
||||
return;
|
||||
};
|
||||
for (e, required) in entries {
|
||||
for (e, _) in entries {
|
||||
match e.strip_prefix('#') {
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags),
|
||||
Some(inner) => self.expand(inner, out, seen_fns, seen_tags, invalid),
|
||||
None => {
|
||||
if (self.functions.contains_key(e) || *required) && seen_fns.insert(e) {
|
||||
if self.functions.contains_key(e) && seen_fns.insert(e) {
|
||||
out.push(e.clone());
|
||||
}
|
||||
}
|
||||
@@ -924,6 +983,97 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_metadata_is_required_before_contents_are_discovered() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-folder-metadata-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for (name, metadata) in [
|
||||
("broken", Some("{")),
|
||||
("root_empty", Some("{}")),
|
||||
("root_array", Some("[]")),
|
||||
("pack_empty", Some(r#"{"pack":{}}"#)),
|
||||
("pack_string", Some(r#"{"pack":"bad"}"#)),
|
||||
("missing", None),
|
||||
(
|
||||
"full",
|
||||
Some(
|
||||
r#"{"pack":{"description":"full","pack_format":121,"min_format":[121,0],"max_format":[121,0]}}"#,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
Some(r#"{"pack":{"description":"description only"}}"#),
|
||||
),
|
||||
("numeric", Some(r#"{"pack":{"description":42}}"#)),
|
||||
("null", Some(r#"{"pack":{"description":null}}"#)),
|
||||
("bom", Some("\u{feff}{\"pack\":{\"description\":\"BOM\"}}")),
|
||||
(
|
||||
"comment",
|
||||
Some("{\"pack\":{\"description\":\"comment\"}}\n// trailing"),
|
||||
),
|
||||
] {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(dir.join("data/h146/function")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("data/h146/function/{name}.mcfunction")),
|
||||
"scoreboard players set admitted h146 1\n",
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(metadata) = metadata {
|
||||
std::fs::write(dir.join("pack.mcmeta"), metadata).unwrap();
|
||||
}
|
||||
}
|
||||
let packs = discover_folder(&root);
|
||||
assert_eq!(
|
||||
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||
[
|
||||
"file/bom",
|
||||
"file/comment",
|
||||
"file/description",
|
||||
"file/full",
|
||||
"file/numeric"
|
||||
]
|
||||
);
|
||||
let store = DataStore::load(&packs.iter().collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
store
|
||||
.functions
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"h146:bom",
|
||||
"h146:comment",
|
||||
"h146:description",
|
||||
"h146:full",
|
||||
"h146:numeric"
|
||||
]
|
||||
);
|
||||
let numeric = packs.iter().find(|p| p.id == "file/numeric").unwrap();
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_slice(&numeric.read("pack.mcmeta").unwrap()).unwrap();
|
||||
assert_eq!(raw["pack"]["description"], 42);
|
||||
// Builtin and feature construction still have their independent paths.
|
||||
let resources = root.join("resources");
|
||||
let feature = resources.join("data/minecraft/datapacks/feature");
|
||||
std::fs::create_dir_all(&feature).unwrap();
|
||||
std::fs::write(
|
||||
feature.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"feature"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
feature_packs(&resources)
|
||||
.iter()
|
||||
.map(|p| p.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["feature"]
|
||||
);
|
||||
assert_eq!(DataPack::builtin(&resources).id, "vanilla");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
@@ -961,7 +1111,7 @@ mod tests {
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "pack.mcmeta", r#"{"pack":{"description":"fixture"}}"#);
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(pack, "data/p/structure/room/a.nbt", pack);
|
||||
write(
|
||||
@@ -1132,6 +1282,20 @@ mod tests {
|
||||
#[test]
|
||||
fn tag_resolution_dedupes_fast_ends_cycles_and_nests_deeply() {
|
||||
let mut store = DataStore::default();
|
||||
// Every referenced function exists; missing required ones invalidate a tag.
|
||||
for id in (0..25_000)
|
||||
.map(|i| format!("t:f{i}"))
|
||||
.chain(["t:x", "t:y", "t:deep"].map(str::to_owned))
|
||||
{
|
||||
store.functions.insert(
|
||||
id.clone(),
|
||||
Function {
|
||||
id,
|
||||
lines: Vec::new(),
|
||||
parameters: Vec::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let big: Vec<(String, bool)> = (0..50_000)
|
||||
.map(|i| (format!("t:f{}", i % 25_000), true))
|
||||
.collect();
|
||||
@@ -1216,13 +1380,13 @@ mod tests {
|
||||
write(
|
||||
"fns",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write(
|
||||
"off",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
r#"{"pack":{"description":"fixture","min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct EffectInstance {
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
pub visible: bool,
|
||||
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
|
||||
pub show_icon: bool,
|
||||
pub hidden: Option<Box<EffectInstance>>,
|
||||
}
|
||||
impl EffectInstance {
|
||||
@@ -24,9 +26,18 @@ impl EffectInstance {
|
||||
amplifier: amplifier.clamp(0, 255),
|
||||
ambient: false,
|
||||
visible: true,
|
||||
show_icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
|
||||
/// the icon shows exactly when the particles do.
|
||||
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
|
||||
self.ambient = ambient;
|
||||
self.visible = visible;
|
||||
self.show_icon = visible;
|
||||
self
|
||||
}
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
@@ -71,6 +82,10 @@ impl EffectInstance {
|
||||
self.visible = take.visible;
|
||||
changed = true;
|
||||
}
|
||||
if take.show_icon != self.show_icon {
|
||||
self.show_icon = take.show_icon;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `MobEffectInstance.tickClient`: a client counts its effects down
|
||||
@@ -98,6 +113,7 @@ impl EffectInstance {
|
||||
self.amplifier = h.amplifier;
|
||||
self.ambient = h.ambient;
|
||||
self.visible = h.visible;
|
||||
self.show_icon = h.show_icon;
|
||||
self.hidden = h.hidden;
|
||||
}
|
||||
}
|
||||
@@ -116,8 +132,9 @@ pub enum EffectAction {
|
||||
}
|
||||
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
|
||||
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
|
||||
let every = |base: i32| {
|
||||
let interval = base >> amp;
|
||||
let interval = base.wrapping_shr(amp as u32);
|
||||
interval <= 0 || tick % interval == 0
|
||||
};
|
||||
match id {
|
||||
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
|
||||
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
|
||||
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
|
||||
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
|
||||
4i32.wrapping_shl(amp as u32).max(0) as f32,
|
||||
)),
|
||||
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(6 << amp.min(30)) as f32,
|
||||
6i32.wrapping_shl(amp as u32) as f32,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
match id {
|
||||
"instant_health" => Some(EffectAction::Heal(
|
||||
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"instant_damage" => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
_ => None,
|
||||
@@ -168,6 +187,57 @@ pub enum OmenTick {
|
||||
/// It added this effect, then returned false.
|
||||
Add(EffectInstance),
|
||||
}
|
||||
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedEffect {
|
||||
pub id: String,
|
||||
pub duration: i32,
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
/// `show_particles`.
|
||||
pub particles: bool,
|
||||
/// `show_icon`.
|
||||
pub icon: bool,
|
||||
/// `hidden_effect`: the weaker, longer instance of the same effect that
|
||||
/// comes back when this one ends.
|
||||
pub hidden: Option<Box<SavedEffect>>,
|
||||
}
|
||||
impl SavedEffect {
|
||||
/// Ambient off, particles and icon shown, nothing hidden: the codec's
|
||||
/// defaults.
|
||||
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
duration,
|
||||
amplifier,
|
||||
ambient: false,
|
||||
particles: true,
|
||||
icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
fn of(e: &EffectInstance) -> Self {
|
||||
Self {
|
||||
id: potions::namespaced(e.id).to_owned(),
|
||||
duration: e.duration,
|
||||
amplifier: e.amplifier,
|
||||
ambient: e.ambient,
|
||||
particles: e.visible,
|
||||
icon: e.show_icon,
|
||||
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
|
||||
/// with it.
|
||||
fn instance(&self, id: &'static str) -> EffectInstance {
|
||||
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
|
||||
e.ambient = self.ambient;
|
||||
e.visible = self.particles;
|
||||
e.show_icon = self.icon;
|
||||
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
|
||||
e
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
@@ -200,39 +270,25 @@ impl PlayerEffects {
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, particles visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
/// The effects of a saved player (`Player.active_effects`), each with
|
||||
/// the instances it hides.
|
||||
pub fn from_saved(saved: &[SavedEffect]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
effects.add(e);
|
||||
for s in saved {
|
||||
if let Some(def) = potions::effect(&s.id) {
|
||||
effects.add(s.instance(def.id));
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `Player.active_effects` for the save.
|
||||
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
pub fn saved(&self) -> Vec<SavedEffect> {
|
||||
self.list.iter().map(SavedEffect::of).collect()
|
||||
}
|
||||
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
|
||||
/// particles visible and the icon shown (always, for these effects).
|
||||
/// particles visible and the icon shown.
|
||||
pub fn flags(e: &EffectInstance) -> i8 {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
|
||||
@@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
|
||||
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
|
||||
/// at least 0 (#11386).
|
||||
#[test]
|
||||
fn heal_and_harm_shift_like_java() {
|
||||
let magic = "minecraft:magic";
|
||||
assert_eq!(
|
||||
instant("instant_health", 32, 1.0),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
assert_eq!(
|
||||
instant("instant_damage", 33, 1.0),
|
||||
Some(EffectAction::Hurt(magic, 12.0))
|
||||
);
|
||||
assert_eq!(
|
||||
instant("instant_health", 1, 0.5),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic("instant_health", 1, 32),
|
||||
Some(EffectAction::Heal(4.0))
|
||||
);
|
||||
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
|
||||
assert_eq!(
|
||||
periodic("instant_health", 1, 29),
|
||||
Some(EffectAction::Heal(0.0))
|
||||
);
|
||||
assert_eq!(
|
||||
periodic("instant_damage", 1, 30),
|
||||
Some(EffectAction::Hurt(magic, i32::MIN as f32))
|
||||
);
|
||||
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
|
||||
assert_eq!(periodic("regeneration", 49, 32), None);
|
||||
assert_eq!(
|
||||
periodic("regeneration", 50, 32),
|
||||
Some(EffectAction::Heal(1.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
|
||||
/// false)`) shows neither particles nor its icon; a renewal that shows the
|
||||
/// icon turns it back on (`MobEffectInstance.update`).
|
||||
#[test]
|
||||
fn an_effect_without_particles_hides_its_icon() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
|
||||
let darkness = fx.get("darkness").unwrap();
|
||||
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
|
||||
assert_eq!(PlayerEffects::flags(darkness), 0);
|
||||
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
|
||||
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
|
||||
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
|
||||
|
||||
// Details keeps show_icon apart from show_particles.
|
||||
let mut speed = EffectInstance::new("speed", 100, 0);
|
||||
speed.show_icon = false;
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(speed);
|
||||
let saved = fx.saved();
|
||||
assert_eq!(
|
||||
saved,
|
||||
[SavedEffect {
|
||||
icon: false,
|
||||
..SavedEffect::new("minecraft:speed", 100, 0)
|
||||
}]
|
||||
);
|
||||
let loaded = PlayerEffects::from_saved(&saved);
|
||||
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
|
||||
}
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
@@ -531,6 +655,21 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
#[test]
|
||||
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
|
||||
// /effect give poison 6 s level II over poison 30 s level I, saved
|
||||
// and loaded: when level II runs out, level I resumes with what is
|
||||
// left of its 30 s.
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("poison", 600, 0));
|
||||
fx.add(EffectInstance::new("poison", 120, 1));
|
||||
let mut loaded = PlayerEffects::from_saved(&fx.saved());
|
||||
for _ in 0..120 {
|
||||
loaded.tick();
|
||||
}
|
||||
let poison = loaded.get("poison").expect("level I resumed");
|
||||
assert_eq!((poison.amplifier, poison.duration), (0, 480));
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
@@ -546,11 +685,11 @@ mod tests {
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
|
||||
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
|
||||
// Without its mod the saved effect is dropped, as an unknown id is.
|
||||
assert!(
|
||||
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
|
||||
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -399,8 +399,11 @@ pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) {
|
||||
pub fn sky_darken(environment: &Environment, day_time: i64, rain: f32, thunder: f32) -> i32 {
|
||||
let mut sky = environment.sample(day_time, None, None);
|
||||
apply_weather(&mut sky, rain, thunder);
|
||||
// Level.updateSkyBrightness: darkening from the sky light level.
|
||||
(15.0 - 15.0 * sky.sky_light_level).round() as i32
|
||||
// Level.updateSkyBrightness: `(int)(15.0F - skyLightLevel)`, the float
|
||||
// truncated, not rounded (#11323): the darkening steps up only once the
|
||||
// light has fallen a whole level. `sky_light_level` is the attribute as a
|
||||
// fraction of 15, so the product is its raw value in `f32`.
|
||||
(15.0 - 15.0 * sky.sky_light_level) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
/// The group of the mod that replaced an extra.
|
||||
const MODS: &str = "horizon.pages.mods";
|
||||
const ZOOM: &str = "horizon.extras.zoom";
|
||||
const ZOOM_INSTALL: &str = "horizon.extras.zoom_mod_install";
|
||||
const MOD_SETTINGS: &str = "horizon.mods.settings";
|
||||
/// Its descriptions run long, as tooltips they were: most need more than two lines.
|
||||
const NOTE_LINES: usize = 5;
|
||||
/// The official native mod that took over from the Zoom extra (#8713).
|
||||
const ZOOM_ID: &str = "zoom";
|
||||
|
||||
/// A row of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExtraRow {
|
||||
Item(Item),
|
||||
/// The Zoom mod, at this place among the installed ones, on or off.
|
||||
Zoom(usize),
|
||||
/// Its own settings page.
|
||||
ZoomSettings(usize),
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
// A setting of a switch that is off does nothing, so it is greyed out.
|
||||
let live = item
|
||||
.parent()
|
||||
.is_none_or(|parent| parent.switch(e) != Some(false));
|
||||
Row::new(ExtraRow::Item(item), item.caption_text(), value)
|
||||
.about(item.description_text())
|
||||
.live(live)
|
||||
}
|
||||
|
||||
/// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now; Extras keeps a way to
|
||||
/// the one players look for here.
|
||||
fn zoom_rows(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let installed = &s.mods.installed;
|
||||
let found = installed
|
||||
.iter()
|
||||
.position(|m| m.id == ZOOM_ID && m.native.is_some());
|
||||
let Some(i) = found else {
|
||||
let note = Row::new(ExtraRow::ZoomInstall, lang::get(ZOOM_INSTALL), Value::Note);
|
||||
return vec![note.live(false)];
|
||||
};
|
||||
let about = installed[i].problem.as_ref();
|
||||
let about = about.unwrap_or(&installed[i].description).clone();
|
||||
let on = Value::Switch(s.mods.is_on(i));
|
||||
let mut rows = vec![Row::new(ExtraRow::Zoom(i), lang::get(ZOOM), on).about(about)];
|
||||
let page = installed[i].native.as_ref();
|
||||
if page.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty()) {
|
||||
let name = lang::get(MOD_SETTINGS);
|
||||
rows.push(Row::new(ExtraRow::ZoomSettings(i), name, Value::Press));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The page itself holds nothing: everything it shows is in the settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ExtrasScreen;
|
||||
|
||||
impl Screen for ExtrasScreen {
|
||||
type Page = ();
|
||||
type Row = ExtraRow;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
rows: zoom_rows(s),
|
||||
}];
|
||||
for row in extras::rows() {
|
||||
match row {
|
||||
Err(name) => groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
}),
|
||||
Ok(item) => {
|
||||
if let Some(group) = groups.last_mut() {
|
||||
group.rows.push(item_row(item, s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Sheet {
|
||||
id: (),
|
||||
name: lang::get(TITLE),
|
||||
about: lang::get(ABOUT),
|
||||
groups,
|
||||
}]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::ZoomSettings(i) => vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Push(Page::NativeMod(i)),
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings_shell::{Hosted, Shell};
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
fn rows_of(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let sheets = ExtrasScreen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).collect()
|
||||
}
|
||||
|
||||
/// Greyed exactly while its own switch is off, so it takes no press then.
|
||||
#[test]
|
||||
fn a_setting_of_a_switch_that_is_off_is_greyed_and_takes_no_press() {
|
||||
let mut s = Settings::default();
|
||||
let live = |s: &Settings| {
|
||||
let wheel = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
rows_of(s).iter().find(|r| r.id == wheel).unwrap().live
|
||||
};
|
||||
s.extras.mouse_tweaks = false;
|
||||
assert!(!live(&s));
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
let before = s.extras.mouse_tweaks_wheel;
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(s.extras.mouse_tweaks_wheel, before);
|
||||
s.extras.mouse_tweaks = true;
|
||||
assert!(live(&s));
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_flips_a_switch_and_leaves_the_mod_to_scroll() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::Sort);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, row, true),
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
|
||||
// Copyright (c) 2022 The Create Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeFluidRenderOptions {
|
||||
pub argb: Option<u32>,
|
||||
pub render_bottom: bool,
|
||||
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
|
||||
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
|
||||
pub uv_shrink_ratio: f32,
|
||||
}
|
||||
|
||||
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub normal: [i8; 3],
|
||||
}
|
||||
|
||||
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
|
||||
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
|
||||
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
|
||||
}
|
||||
|
||||
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
|
||||
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
|
||||
pub fn fluid_box(
|
||||
bounds: [f32; 6],
|
||||
options: NativeFluidRenderOptions,
|
||||
mut emit: impl FnMut(FluidVertex),
|
||||
) -> bool {
|
||||
if !options.uv_shrink_ratio.is_finite()
|
||||
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|
||||
|| bounds
|
||||
.iter()
|
||||
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|
||||
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let tiles =
|
||||
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
|
||||
let quads = tiles[0] * tiles[1] * 2
|
||||
+ tiles[1] * tiles[2] * 2
|
||||
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
|
||||
if quads > 65_536 {
|
||||
return false;
|
||||
}
|
||||
let [x0, y0, z0, x1, y1, z1] = bounds;
|
||||
for (side, face) in [
|
||||
(Direction::Down, [x0, z0, x1, z1, y0]),
|
||||
(Direction::Up, [x0, z0, x1, z1, y1]),
|
||||
(Direction::North, [x0, y0, x1, y1, z0]),
|
||||
(Direction::South, [x0, y0, x1, y1, z1]),
|
||||
(Direction::West, [z0, y0, z1, y1, x0]),
|
||||
(Direction::East, [z0, y0, z1, y1, x1]),
|
||||
] {
|
||||
if side != Direction::Down || options.render_bottom {
|
||||
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
|
||||
let [a0, b0, a1, b1, plane] = face;
|
||||
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
|
||||
let shrink = ratio * 0.25;
|
||||
let lerp = |v| v + shrink * (0.5 - v);
|
||||
let mut a = a0;
|
||||
while a < a1 {
|
||||
let end_a = (a.floor() + 1.0).min(a1);
|
||||
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
|
||||
(end_a.ceil() - end_a, end_a.ceil() - a)
|
||||
} else {
|
||||
(a - a.floor(), end_a - a.floor())
|
||||
};
|
||||
let (u0, u1) = (lerp(u0), lerp(u1));
|
||||
let mut b = b0;
|
||||
while b < b1 {
|
||||
let end_b = (b.floor() + 1.0).min(b1);
|
||||
let (v0, v1) = if side == Direction::Up {
|
||||
(b - b.floor(), end_b - b.floor())
|
||||
} else {
|
||||
(end_b.ceil() - end_b, end_b.ceil() - b)
|
||||
};
|
||||
let (v0, v1) = (lerp(v0), lerp(v1));
|
||||
let positions = match side {
|
||||
Direction::West | Direction::East => {
|
||||
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
|
||||
[
|
||||
[plane, end_b, near],
|
||||
[plane, b, near],
|
||||
[plane, b, far],
|
||||
[plane, end_b, far],
|
||||
]
|
||||
}
|
||||
Direction::North | Direction::South => {
|
||||
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
|
||||
[
|
||||
[near, end_b, plane],
|
||||
[near, b, plane],
|
||||
[far, b, plane],
|
||||
[far, end_b, plane],
|
||||
]
|
||||
}
|
||||
Direction::Down | Direction::Up => {
|
||||
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
|
||||
[
|
||||
[a, plane, near],
|
||||
[a, plane, far],
|
||||
[end_a, plane, far],
|
||||
[end_a, plane, near],
|
||||
]
|
||||
}
|
||||
};
|
||||
let normal = match side {
|
||||
Direction::Down => [0, -1, 0],
|
||||
Direction::Up => [0, 1, 0],
|
||||
Direction::North => [0, 0, -1],
|
||||
Direction::South => [0, 0, 1],
|
||||
Direction::West => [-1, 0, 0],
|
||||
Direction::East => [1, 0, 0],
|
||||
};
|
||||
for (pos, uv) in positions
|
||||
.into_iter()
|
||||
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||
{
|
||||
out(FluidVertex { pos, uv, normal });
|
||||
}
|
||||
b = end_b;
|
||||
}
|
||||
a = end_a;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn source_tiling_is_outward_and_does_not_inset_geometry() {
|
||||
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
|
||||
let mut result = Vec::new();
|
||||
assert!(fluid_box(
|
||||
bounds,
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: false,
|
||||
uv_shrink_ratio: 4.0 / 2048.0,
|
||||
},
|
||||
|v| result.push(v),
|
||||
));
|
||||
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
|
||||
for quad in result.as_chunks::<4>().0 {
|
||||
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
|
||||
assert!(
|
||||
(b - a)
|
||||
.cross(c - a)
|
||||
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
|
||||
> 0.0
|
||||
);
|
||||
assert!(
|
||||
quad.iter()
|
||||
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.iter()
|
||||
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
|
||||
);
|
||||
assert!(!fluid_box(
|
||||
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: true,
|
||||
uv_shrink_ratio: 0.0,
|
||||
},
|
||||
|_| panic!("invalid geometry must fail before emitting"),
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn luminosity_changes_only_block_light() {
|
||||
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0xe030, 3), 0xe030);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
environment::Sky,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use std::path::Path;
|
||||
|
||||
/// `FogType` of the camera position.
|
||||
@@ -64,7 +65,7 @@ impl FogTables {
|
||||
}
|
||||
|
||||
/// Fog state that evolves over time.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FogState {
|
||||
/// `LocalPlayer.waterVisionTime` (0..600).
|
||||
pub water_vision_time: i32,
|
||||
@@ -72,24 +73,28 @@ pub struct FogState {
|
||||
pub rain_fog_multiplier: f32,
|
||||
/// `LightmapRenderStateExtractor.blockLightFlicker`.
|
||||
pub block_light_flicker: f32,
|
||||
flicker_seed: u64,
|
||||
/// `LightmapRenderStateExtractor.random`: `RandomSource.create()`.
|
||||
flicker_random: LegacyRandom,
|
||||
}
|
||||
impl Default for FogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
water_vision_time: 0,
|
||||
rain_fog_multiplier: 0.0,
|
||||
block_light_flicker: 0.0,
|
||||
flicker_random: crate::random_source::create(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FogState {
|
||||
fn next_float(&mut self) -> f32 {
|
||||
// splitmix64: the vanilla source is unseeded `RandomSource.create()`.
|
||||
self.flicker_seed = self.flicker_seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.flicker_seed;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
((z ^ (z >> 31)) >> 40) as f32 / (1u32 << 24) as f32
|
||||
}
|
||||
/// `LightmapRenderStateExtractor.tick`: the torch light flickers.
|
||||
pub fn tick_flicker(&mut self) {
|
||||
let r = &mut self.flicker_random;
|
||||
let (a, b, c, d) = (
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
self.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
r.next_float(),
|
||||
);
|
||||
self.block_light_flicker += (a - b) * c * d * 0.1;
|
||||
self.block_light_flicker *= 0.9;
|
||||
@@ -413,6 +418,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
|
||||
/// the sky reaches the eye, halved in a biome without precipitation.
|
||||
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
rain * sky * if biome_rains { 1.0 } else { 0.5 }
|
||||
}
|
||||
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
|
||||
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
|
||||
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
|
||||
@@ -428,10 +439,14 @@ pub fn update_rain_fog(
|
||||
biome_rains: bool,
|
||||
delta_ticks: f32,
|
||||
) {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
|
||||
let target = rain_fog_target(rain, sky_light, biome_rains);
|
||||
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
|
||||
}
|
||||
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
|
||||
/// at its level and easing would wait for ticks that do not come.
|
||||
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
|
||||
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
(c >> 16 & 255) as f32 / 255.0,
|
||||
@@ -726,6 +741,30 @@ mod tests {
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
}
|
||||
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
|
||||
/// of the way, as before.
|
||||
#[test]
|
||||
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
|
||||
assert_eq!(
|
||||
rain_fog_target(1.0, 15, false),
|
||||
0.5,
|
||||
"a dry biome halves it"
|
||||
);
|
||||
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
|
||||
let start = FogState {
|
||||
rain_fog_multiplier: 0.1,
|
||||
..FogState::default()
|
||||
};
|
||||
let mut settled = start.clone();
|
||||
settle_rain_fog(&mut settled, 0.5, 15, true);
|
||||
assert_eq!(settled.rain_fog_multiplier, 0.5);
|
||||
let mut eased = start;
|
||||
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
|
||||
assert_eq!(
|
||||
eased.rain_fog_multiplier.to_bits(),
|
||||
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rain_fog_pulls_fog_in() {
|
||||
let sky = Sky {
|
||||
|
||||
@@ -126,11 +126,16 @@ struct Cache {
|
||||
random: horizon_core::random::LegacyRandom,
|
||||
}
|
||||
pub struct Font {
|
||||
/// The atlas rects of the sheets under `textures/font`.
|
||||
/// The atlas rects of every texture, by resource name: a bitmap provider's
|
||||
/// `file` may name any texture, not only one under `textures/font`.
|
||||
sheets: HashMap<String, Rect>,
|
||||
missing: Glyph,
|
||||
white: Rect,
|
||||
root: PathBuf,
|
||||
/// The game's own resources, below a pack overlay: a font file the overlay
|
||||
/// lacks (a pack adds providers over vanilla's, or the overlay left a
|
||||
/// directory out) is read from here, as vanilla's resource stack does.
|
||||
fallback: Option<PathBuf>,
|
||||
/// Atlas size and the reserved dynamic region (y, height).
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
@@ -199,6 +204,13 @@ fn parse_hex(text: &[u8], out: &mut HashMap<u32, HexGlyph>) {
|
||||
out.insert(codepoint, HexGlyph { rows, left, right });
|
||||
}
|
||||
}
|
||||
/// A resource file's bytes, from `root` or, when the overlay lacks it, from
|
||||
/// the game's own resources below (`defaults`).
|
||||
fn read_resource(root: &Path, defaults: Option<&Path>, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(root.join(path))
|
||||
.ok()
|
||||
.or_else(|| defaults.and_then(|d| std::fs::read(d.join(path)).ok()))
|
||||
}
|
||||
fn read_json(root: &Path, path: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_slice(&std::fs::read(root.join(path)).ok()?).ok()
|
||||
}
|
||||
@@ -281,6 +293,7 @@ fn hex_advance(h: &HexGlyph) -> f32 {
|
||||
/// pixels, copied into the atlas when first drawn.
|
||||
fn load_bitmap(
|
||||
root: &Path,
|
||||
defaults: Option<&Path>,
|
||||
p: &serde_json::Value,
|
||||
sheets: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
@@ -290,22 +303,36 @@ fn load_bitmap(
|
||||
let mut out = HashMap::new();
|
||||
let file = p["file"].as_str().unwrap_or("");
|
||||
let (ns, name) = file.split_once(':').unwrap_or(("minecraft", file));
|
||||
let in_atlas = (ns == "minecraft")
|
||||
.then(|| sheets.get(name.trim_end_matches(".png")).copied())
|
||||
.flatten();
|
||||
let name = name.trim_end_matches(".png");
|
||||
// The atlas names the default namespace's textures by their path and every
|
||||
// other namespace's as `namespace:path` (HudAtlas::build).
|
||||
let atlas_name = if ns == "minecraft" {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{ns}:{name}")
|
||||
};
|
||||
let in_atlas = sheets.get(&atlas_name).copied();
|
||||
// The sheet: its size, and its pixels when they are not in the atlas.
|
||||
let (sw, sh, sheet) = match in_atlas {
|
||||
Some(rect) => {
|
||||
let (w, h) = rect.texels(atlas);
|
||||
(w, h, None)
|
||||
}
|
||||
None => match crate::hud::decode(&root.join(resource_path(file, "textures", ""))) {
|
||||
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
|
||||
Err(_) => {
|
||||
log::warn!("font sheet {file} could not be read");
|
||||
return out;
|
||||
None => {
|
||||
let path = resource_path(file, "textures", "");
|
||||
let sheet = crate::hud::decode(&root.join(&path)).or_else(|first| {
|
||||
defaults
|
||||
.and_then(|d| crate::hud::decode(&d.join(&path)).ok())
|
||||
.ok_or(first)
|
||||
});
|
||||
match sheet {
|
||||
Ok((w, h, rgba)) => (w, h, Some(Arc::new((w, rgba)))),
|
||||
Err(_) => {
|
||||
log::warn!("font sheet {file} could not be read");
|
||||
return out;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
let rows: Vec<Vec<char>> = p["chars"]
|
||||
.as_array()
|
||||
@@ -382,11 +409,13 @@ fn load_bitmap(
|
||||
}
|
||||
/// `TrueTypeGlyphProviderDefinition.load`: a TrueType file (not CFF) under
|
||||
/// `font/`, its Unicode characters but the `skip` ones.
|
||||
fn load_true_type(root: &Path, p: &serde_json::Value) -> Option<TrueType> {
|
||||
fn load_true_type(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> Option<TrueType> {
|
||||
let file = p["file"].as_str()?;
|
||||
let data = std::fs::read(root.join(resource_path(file, "font", "")))
|
||||
.map_err(|e| log::warn!("font file {file}: {e}"))
|
||||
.ok()?;
|
||||
let path = resource_path(file, "font", "");
|
||||
let Some(data) = read_resource(root, defaults, &path) else {
|
||||
log::warn!("font file {file} could not be read");
|
||||
return None;
|
||||
};
|
||||
// FT_Get_Font_Format must say TrueType: CFF outlines ("OTTO") are refused.
|
||||
if data.starts_with(b"OTTO") {
|
||||
log::warn!("font file {file} is not in TTF format");
|
||||
@@ -486,13 +515,16 @@ impl TrueType {
|
||||
)
|
||||
}
|
||||
}
|
||||
fn load_hex(root: &Path, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
|
||||
fn load_hex(root: &Path, defaults: Option<&Path>, p: &serde_json::Value) -> HashMap<u32, HexGlyph> {
|
||||
let mut glyphs = HashMap::new();
|
||||
let Some(file) = p["hex_file"].as_str() else {
|
||||
return glyphs;
|
||||
};
|
||||
let path = root.join(resource_path(file, "", "").replace("//", "/"));
|
||||
let Ok(zip) = Zip::open(&path) else {
|
||||
let path = resource_path(file, "", "").replace("//", "/");
|
||||
let Some(zip) = Zip::open(&root.join(&path))
|
||||
.ok()
|
||||
.or_else(|| defaults.and_then(|d| Zip::open(&d.join(&path)).ok()))
|
||||
else {
|
||||
return glyphs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
@@ -603,7 +635,24 @@ impl Font {
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
Self::load_with(root, sprites, pixels, atlas, region, white, false)
|
||||
Self::load_with(root, None, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// [`Font::load`] over a pack overlay: a sheet, TrueType file or Unihex
|
||||
/// file the overlay lacks is read from `defaults`, the game's own
|
||||
/// resources, as vanilla's resource stack does. (Definitions stay the
|
||||
/// overlay's: a pack's filter may hide one from a lower pack.)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn load_over(
|
||||
root: &Path,
|
||||
defaults: &Path,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
let fallback = (root != defaults).then_some(defaults);
|
||||
Self::load_with(root, fallback, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// A server measures text but never draws it. Decode bitmap sheets only
|
||||
/// to obtain advances, then release their pixels; never rasterize glyphs.
|
||||
@@ -616,10 +665,21 @@ impl Font {
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
|
||||
Self::load_with(
|
||||
root,
|
||||
None,
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
white,
|
||||
true,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn load_with(
|
||||
root: &Path,
|
||||
fallback: Option<&Path>,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
@@ -627,11 +687,7 @@ impl Font {
|
||||
white: Rect,
|
||||
metrics_only: bool,
|
||||
) -> Self {
|
||||
let sheets = sprites
|
||||
.iter()
|
||||
.filter(|(k, _)| k.starts_with("font/"))
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
let sheets = sprites.clone();
|
||||
let font = Self {
|
||||
sheets,
|
||||
missing: Glyph {
|
||||
@@ -645,6 +701,7 @@ impl Font {
|
||||
},
|
||||
white,
|
||||
root: root.to_owned(),
|
||||
fallback: fallback.map(|p| p.to_owned()),
|
||||
atlas,
|
||||
region,
|
||||
metrics_only,
|
||||
@@ -656,11 +713,8 @@ impl Font {
|
||||
pending: Vec::new(),
|
||||
full: false,
|
||||
},
|
||||
random: horizon_core::random::LegacyRandom::new(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64),
|
||||
),
|
||||
// `Font.random`: `RandomSource.create()`.
|
||||
random: crate::random_source::create(),
|
||||
}),
|
||||
generation: std::cell::Cell::new(next_generation()),
|
||||
uniform: false,
|
||||
@@ -698,6 +752,7 @@ impl Font {
|
||||
Filter::of(p),
|
||||
load_bitmap(
|
||||
&self.root,
|
||||
self.fallback.as_deref(),
|
||||
p,
|
||||
&self.sheets,
|
||||
pixels,
|
||||
@@ -708,7 +763,7 @@ impl Font {
|
||||
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
|
||||
"ttf" => Some(Provider::TrueType(
|
||||
Filter::of(p),
|
||||
Box::new(load_true_type(&self.root, p)?),
|
||||
Box::new(load_true_type(&self.root, self.fallback.as_deref(), p)?),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
@@ -868,7 +923,8 @@ impl Font {
|
||||
}
|
||||
}
|
||||
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
|
||||
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
|
||||
let glyphs = glyphs
|
||||
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
|
||||
if let Some(h) = glyphs.get(&u32::from(c)).copied()
|
||||
&& !(non_fishy && fishy_advance(hex_advance(&h)))
|
||||
{
|
||||
@@ -1026,7 +1082,8 @@ impl Font {
|
||||
}
|
||||
}
|
||||
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
|
||||
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
|
||||
let glyphs = glyphs
|
||||
.get_or_insert_with(|| load_hex(&self.root, self.fallback.as_deref(), def));
|
||||
if let Some(h) = glyphs.get(&u32::from(c)) {
|
||||
return Some(hex_advance(h));
|
||||
}
|
||||
@@ -1512,6 +1569,52 @@ mod tests {
|
||||
assert!(texels.iter().any(|t| t[0] == 255));
|
||||
}
|
||||
|
||||
/// A bitmap provider's `file` can name any texture. When the atlas has that
|
||||
/// texture (HUD packs keep their glyph sheets outside `textures/font`), the
|
||||
/// glyphs are read from it instead of being copied into the small run-time
|
||||
/// region one by one; the region then cannot fill up and turn later glyphs
|
||||
/// into the missing one, whose advance shifts the rest of a HUD line.
|
||||
#[test]
|
||||
fn a_bitmap_sheet_outside_font_is_read_from_the_atlas() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let (_, _, pixels) = crate::hud::decode_bytes(&sheet).unwrap();
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 8,
|
||||
};
|
||||
for (ns, file, key) in [
|
||||
("minecraft", "minecraft:hud/sheet.png", "hud/sheet"),
|
||||
(
|
||||
"hudengine",
|
||||
"hudengine:font/sheet.png",
|
||||
"hudengine:font/sheet",
|
||||
),
|
||||
] {
|
||||
let def = format!(
|
||||
r#"{{"providers":[{{"type":"bitmap","file":"{file}","ascent":7,"chars":["ab"]}}]}}"#
|
||||
);
|
||||
// The image is only in the atlas: the pack's copy is not on disk.
|
||||
let root = root(
|
||||
ns,
|
||||
&[("assets/minecraft/font/default.json", def.as_bytes())],
|
||||
);
|
||||
let sprites = HashMap::from([(key.to_string(), rect)]);
|
||||
let font = Font::load(&root, &sprites, &pixels, (16, 8), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(font.glyph('a').advance, 3.0, "{ns}");
|
||||
assert_eq!(font.glyph('b').advance, 7.0, "{ns}");
|
||||
assert!(font.glyph('a').rect.is_some(), "{ns}");
|
||||
assert!(
|
||||
font.take_patches().is_empty(),
|
||||
"{ns}: the atlas sheet is not copied into the run-time region"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_pick_their_font_and_unknown_fonts_draw_missing_glyphs() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
|
||||
+1106
-185
File diff suppressed because it is too large.
Load diff
@@ -1040,7 +1040,14 @@ mod tests {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
|
||||
let pipeline = &pipeline;
|
||||
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
|
||||
super::super::SolidPipeline {
|
||||
plain: pipeline(layout, label, "", "", None, None, true),
|
||||
hdr: None,
|
||||
}
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
|
||||
@@ -199,6 +199,8 @@ pub struct FrameInputs {
|
||||
pub skybox: bool,
|
||||
pub end_sky: bool,
|
||||
pub end_sky_sprite: Option<[f32; 4]>,
|
||||
#[serde(default)]
|
||||
pub boss_world_fog: bool,
|
||||
pub environment_fog: Option<(f32, f32)>,
|
||||
pub water_fog: [f32; 3],
|
||||
pub fog: Option<FogData>,
|
||||
@@ -414,6 +416,7 @@ impl Renderer {
|
||||
skybox: frame.skybox,
|
||||
end_sky: frame.end_sky,
|
||||
end_sky_sprite: frame.end_sky_sprite,
|
||||
boss_world_fog: frame.boss_world_fog,
|
||||
environment_fog: frame.environment_fog,
|
||||
water_fog: frame.water_fog,
|
||||
fog: frame.fog,
|
||||
@@ -510,6 +513,7 @@ impl Scene {
|
||||
skybox: f.skybox,
|
||||
end_sky: f.end_sky,
|
||||
end_sky_sprite: f.end_sky_sprite,
|
||||
boss_world_fog: f.boss_world_fog,
|
||||
environment_fog: f.environment_fog,
|
||||
water_fog: f.water_fog,
|
||||
fog: f.fog,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
Blend::Add,
|
||||
Blend::Subtract,
|
||||
Blend::Multiply,
|
||||
Blend::Dodge,
|
||||
Blend::Burn,
|
||||
Blend::Screen,
|
||||
Blend::Replace,
|
||||
Blend::Overlay,
|
||||
Blend::Alpha,
|
||||
];
|
||||
|
||||
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
|
||||
const MAX_DRAWS: usize = 32;
|
||||
/// Dynamic uniform offsets are 256-byte aligned on every backend.
|
||||
const STRIDE: u64 = 256;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
brightness: f32,
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Vertex {
|
||||
position: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
cell: [f32; 4],
|
||||
}
|
||||
|
||||
pub(super) struct CustomSky {
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// One bind group per layer texture of the pack.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
/// This frame's layers: bind group and pipeline indices.
|
||||
frame: Vec<(u16, u8)>,
|
||||
}
|
||||
|
||||
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
|
||||
fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
use wgpu::BlendFactor as F;
|
||||
let (src_factor, dst_factor, operation) = match mode {
|
||||
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
|
||||
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
|
||||
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
|
||||
// dst + dst * src: lightens.
|
||||
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
|
||||
// dst * (1 - src): darkens.
|
||||
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
|
||||
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
|
||||
// dst * src + src * dst.
|
||||
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
|
||||
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
|
||||
};
|
||||
wgpu::BlendComponent {
|
||||
src_factor,
|
||||
dst_factor,
|
||||
operation,
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
for face in Face::ALL {
|
||||
let cell = face.cell();
|
||||
let [u0, v0, u1, v1] = cell;
|
||||
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
|
||||
let corners = face.corners();
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
out.push(Vertex {
|
||||
position: corners[i],
|
||||
uv: uv[i],
|
||||
cell,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl CustomSky {
|
||||
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Custom sky bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: wgpu::BufferSize::new(
|
||||
std::mem::size_of::<Uniform>() as u64
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Custom sky shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Custom sky layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
|
||||
let pipelines = MODES
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
|
||||
// under the layers keeps its own.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(mode),
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Custom sky"),
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Custom sky layers"),
|
||||
size: STRIDE * MAX_DRAWS as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Custom sky box"),
|
||||
contents: bytemuck::cast_slice(&mesh()),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Self {
|
||||
pipelines,
|
||||
layout,
|
||||
sampler,
|
||||
uniform,
|
||||
vertices,
|
||||
groups: Vec::new(),
|
||||
frame: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
|
||||
(image.width, image.height, &image.rgba[..])
|
||||
} else {
|
||||
log::warn!(
|
||||
"custom sky texture of {}x{} too large",
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
(1, 1, &[0u8; 4][..])
|
||||
};
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
rgba,
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.uniform,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
clip: Mat4,
|
||||
draws: &[Draw],
|
||||
) {
|
||||
self.frame.clear();
|
||||
for draw in draws.iter().take(MAX_DRAWS) {
|
||||
if usize::from(draw.image) >= self.groups.len() {
|
||||
continue;
|
||||
}
|
||||
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
|
||||
brightness: draw.brightness.clamp(0.0, 1.0),
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
|
||||
self.frame.push((draw.image, mode as u8));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_drawing(&self) -> bool {
|
||||
!self.frame.is_empty()
|
||||
}
|
||||
|
||||
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
|
||||
/// changed.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
for (i, &(image, mode)) in self.frame.iter().enumerate() {
|
||||
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
&self.groups[usize::from(image)],
|
||||
&[(STRIDE * i as u64) as u32],
|
||||
);
|
||||
pass.draw(0..36, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
let k = a * b;
|
||||
let src = match mode {
|
||||
Blend::Multiply => 1.0 + (t - 1.0) * k,
|
||||
Blend::Overlay => 0.5 + (t - 0.5) * k,
|
||||
Blend::Replace | Blend::Alpha => t,
|
||||
_ => t * k,
|
||||
};
|
||||
let c = blend(mode);
|
||||
let f = |factor: wgpu::BlendFactor| match factor {
|
||||
wgpu::BlendFactor::One => 1.0,
|
||||
wgpu::BlendFactor::Zero => 0.0,
|
||||
wgpu::BlendFactor::Src => src,
|
||||
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
|
||||
wgpu::BlendFactor::Dst => dst,
|
||||
wgpu::BlendFactor::SrcAlpha => k,
|
||||
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
|
||||
match c.operation {
|
||||
wgpu::BlendOperation::Add => s + d,
|
||||
wgpu::BlendOperation::ReverseSubtract => d - s,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_gives_its_documented_result() {
|
||||
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
|
||||
let (d, t) = (0.4, 0.8);
|
||||
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
|
||||
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
|
||||
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
|
||||
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
|
||||
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
|
||||
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
|
||||
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
|
||||
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
|
||||
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
|
||||
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
|
||||
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
|
||||
for mode in MODES {
|
||||
if mode != Blend::Replace {
|
||||
close(mix(mode, d, t, 1.0, 0.0), d);
|
||||
}
|
||||
}
|
||||
// Half faded: add adds half.
|
||||
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_covers_every_face_with_its_cell() {
|
||||
let mesh = mesh();
|
||||
assert_eq!(mesh.len(), 36);
|
||||
for v in &mesh {
|
||||
let [u0, v0, u1, v1] = v.cell;
|
||||
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
|
||||
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
|
||||
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
|
||||
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
|
||||
// brightness 0 leaves the sky as it was.
|
||||
struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
|
||||
@group(0) @binding(2) var sky_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
|
||||
@location(1) @interpolate(flat) cell: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex fn vs_custom_sky(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) cell: vec4<f32>,
|
||||
) -> Out {
|
||||
var out: Out;
|
||||
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
|
||||
out.uv = uv;
|
||||
out.cell = cell;
|
||||
return out;
|
||||
}
|
||||
|
||||
const ADD: u32 = 0u;
|
||||
const SUBTRACT: u32 = 1u;
|
||||
const MULTIPLY: u32 = 2u;
|
||||
const DODGE: u32 = 3u;
|
||||
const BURN: u32 = 4u;
|
||||
const SCREEN: u32 = 5u;
|
||||
const REPLACE: u32 = 6u;
|
||||
const OVERLAY: u32 = 7u;
|
||||
const ALPHA: u32 = 8u;
|
||||
|
||||
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
|
||||
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
|
||||
let t = textureSample(sky_texture, sky_sampler, uv);
|
||||
let k = t.a * layer.brightness;
|
||||
switch layer.mode {
|
||||
// dst * src: white leaves the sky alone.
|
||||
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
|
||||
// 2 * dst * src: grey leaves it alone.
|
||||
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
|
||||
case REPLACE: { return vec4(t.rgb, 1.0); }
|
||||
// mix(dst, src, alpha).
|
||||
case ALPHA: { return vec4(t.rgb, k); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
|
||||
default: { return vec4(t.rgb * k, 1.0); }
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,24 @@ use super::Vertex;
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
|
||||
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
|
||||
const MATERIALS: usize = 16;
|
||||
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
|
||||
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
|
||||
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
|
||||
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
|
||||
pub fn is_native_terrain(layer: u32) -> bool {
|
||||
matches!(
|
||||
layer & 0xffff,
|
||||
FLUID_OVERLAY
|
||||
| TERRAIN_MODEL_SOLID_OVERLAY
|
||||
| TERRAIN_MODEL_CUTOUT_OVERLAY
|
||||
| TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY
|
||||
)
|
||||
}
|
||||
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
|
||||
pub const FLUID_OVERLAY: u32 = 41;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
@@ -31,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38;
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
for vertex in quads {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
if !is_native_terrain(vertex.layer) {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
@@ -60,12 +78,21 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
match layer & 0xffff {
|
||||
TERRAIN_MODEL_SOLID_OVERLAY => return 13,
|
||||
TERRAIN_MODEL_CUTOUT_OVERLAY => return 14,
|
||||
TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15,
|
||||
_ => {}
|
||||
}
|
||||
if layer & 0xffff == FLUID_OVERLAY {
|
||||
return 10;
|
||||
}
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
WORLD_TEXT_OVERLAY => 11,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -148,10 +175,10 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
for runs in &self.runs[10..] {
|
||||
// Fluid and RenderType.text sort their own alpha-blended quads far to
|
||||
// near. Fluid precedes both world-canvas layers, after existing materials
|
||||
// (including eyes moved out of additive).
|
||||
for runs in &self.runs[10..13] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
@@ -169,6 +196,13 @@ impl EntityBatch {
|
||||
distance(b).total_cmp(&distance(a))
|
||||
});
|
||||
}
|
||||
// Native solid/cutout models are depth writing and keep their submission order.
|
||||
for runs in &self.runs[13..] {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
@@ -189,7 +223,57 @@ mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [x, 0.0, 0.0],
|
||||
..geometry(1, false)[0]
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
(FLUID_OVERLAY, 10.0),
|
||||
(WORLD_TEXT_OVERLAY, 11.0),
|
||||
(TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0),
|
||||
(TERRAIN_MODEL_SOLID_OVERLAY, 1.0),
|
||||
(TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0),
|
||||
(TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0),
|
||||
(OIT_ENTITY, 6.0),
|
||||
(0, 7.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
let counts = batch.update(
|
||||
&entities,
|
||||
&quad(0, 8.0),
|
||||
&quad(0, 9.0),
|
||||
&quad(0, 12.0),
|
||||
true,
|
||||
);
|
||||
assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]);
|
||||
let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
xs,
|
||||
[7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0]
|
||||
);
|
||||
assert_eq!(
|
||||
batch.combined[16].layer,
|
||||
TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() {
|
||||
let mut vertices = [geometry(1, false)[0]; 2];
|
||||
vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16;
|
||||
vertices[1].layer = 3;
|
||||
mark_oit(&mut vertices);
|
||||
assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16);
|
||||
assert_eq!(vertices[1].layer, 3 | OIT_ENTITY);
|
||||
}
|
||||
#[test]
|
||||
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
@@ -204,6 +288,8 @@ mod tests {
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
(FLUID_OVERLAY, 11.0),
|
||||
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
@@ -215,12 +301,14 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
[
|
||||
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,12 +408,12 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! copied after the test, which tells which ones it saw: each run of a page
|
||||
//! reserves room for all of its draws, the copy packs the seen ones at its
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
@@ -86,12 +87,12 @@ pub struct Call {
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub solid: SolidPipeline,
|
||||
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
|
||||
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
|
||||
/// then the rest.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: wgpu::RenderPipeline,
|
||||
pub solid_opaque: SolidPipeline,
|
||||
pub solid_cutout: SolidPipeline,
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
@@ -357,7 +358,8 @@ fn plan(
|
||||
impl Flat {
|
||||
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
|
||||
/// terrain pipeline as the per-section ones, without the origin buffer, and without
|
||||
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
|
||||
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
|
||||
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
@@ -372,6 +374,7 @@ impl Flat {
|
||||
Option<wgpu::Face>,
|
||||
bool,
|
||||
) -> wgpu::RenderPipeline,
|
||||
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
|
||||
) -> Self {
|
||||
let vertex = wgpu::ShaderStages::VERTEX;
|
||||
let compute = wgpu::ShaderStages::COMPUTE;
|
||||
@@ -432,32 +435,23 @@ impl Flat {
|
||||
Self {
|
||||
// The solid draws read either layout of the solid quads (`mesher::packed`) from
|
||||
// the page; translucent ones keep their vertex buffer.
|
||||
solid: pipeline(
|
||||
solid: solid(
|
||||
&layout,
|
||||
"Terrain solid (flat)",
|
||||
"vs_section_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
solid_opaque: solid(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_section_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
solid_cutout: solid(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_section_flat_cutout",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
true,
|
||||
),
|
||||
// The mirror flips the winding: its solid terrain culls the front.
|
||||
solid_mirror: pipeline(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
|
||||
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
|
||||
label: Some("Still fluid (nearest texels, linear mipmaps)"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
lod_min_clamp: 0.0,
|
||||
lod_max_clamp: 32.0,
|
||||
compare: None,
|
||||
anisotropy_clamp: 1,
|
||||
border_color: None,
|
||||
};
|
||||
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
constants: &[(&str, f64)],
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Still fluid"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -72,8 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
|
||||
/// hand marked it, for `resolve`.
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
/// The layered frame's HUD target: what the HUD draws on.
|
||||
hud: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
|
||||
passes: Option<[wgpu::BindGroup; 3]>,
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
|
||||
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
|
||||
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
|
||||
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
|
||||
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
|
||||
/// (`composite`).
|
||||
struct Layered {
|
||||
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
@@ -155,6 +176,7 @@ pub struct Hdr {
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
layered: Option<Layered>,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
@@ -181,28 +203,67 @@ impl Hdr {
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
coverage: bool,
|
||||
) -> Self {
|
||||
let debug = std::env::var("HORIZON_HDR_DEBUG").ok();
|
||||
Self::with_debug(device, queue, format, output, coverage, debug.as_deref())
|
||||
}
|
||||
|
||||
fn with_debug(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
coverage: bool,
|
||||
debug: Option<&str>,
|
||||
) -> Self {
|
||||
let coverage_debug = coverage && debug == Some("coverage");
|
||||
// The layered frame's last pass, else today's.
|
||||
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
|
||||
let mut source = include_str!("../hdr.wgsl").to_owned();
|
||||
if coverage_debug {
|
||||
// The debug view wraps the last pass as it is, so the picture under the tint is
|
||||
// exactly the one without it.
|
||||
let entry = format!(
|
||||
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
|
||||
);
|
||||
assert!(
|
||||
source.contains(&entry),
|
||||
"hdr.wgsl's {entry_point} changed its signature"
|
||||
);
|
||||
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
source.push_str(&entry);
|
||||
source.push_str(
|
||||
r#" {
|
||||
let picture = picture_hdr(pos);
|
||||
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
|
||||
// Magenta at paper white, in the output's own encoding.
|
||||
let paper = vec3(params.paper, 0.0, params.paper);
|
||||
let magenta = select(paper, pq(BT709_TO_BT2020 * paper * 80.0), params.pq > 0.5);
|
||||
return vec4(mix(picture.rgb, magenta, c), picture.a);
|
||||
}
|
||||
"#,
|
||||
);
|
||||
}
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
|
||||
let layout = |label, textures: &[u32], params: bool| {
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
|
||||
if params {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
@@ -211,17 +272,22 @@ impl Hdr {
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
});
|
||||
}
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
@@ -234,7 +300,7 @@ impl Hdr {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -246,6 +312,30 @@ impl Hdr {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
Layered {
|
||||
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -288,27 +378,20 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
debug: debug == Some("1"),
|
||||
layered,
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
@@ -324,7 +407,7 @@ impl Hdr {
|
||||
/// The frame every pass draws into this frame, sized like the window.
|
||||
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
if self.targets.as_ref().is_none_or(|t| t.size != size) {
|
||||
let texture = |label, usage| {
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -335,52 +418,185 @@ impl Hdr {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: self.format,
|
||||
format,
|
||||
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let frame = texture(
|
||||
"Horizon HDR frame",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let world = texture(
|
||||
"Horizon HDR world",
|
||||
self.format,
|
||||
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
let layered = self.layered.is_some();
|
||||
let coverage = layered.then(|| {
|
||||
texture(
|
||||
"HUD coverage",
|
||||
super::hud_coverage::FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&frame,
|
||||
&world,
|
||||
coverage.as_ref(),
|
||||
hud.as_ref(),
|
||||
&self.params,
|
||||
);
|
||||
let passes = self
|
||||
.layered
|
||||
.as_ref()
|
||||
.zip(coverage.as_ref().zip(hud.as_ref()))
|
||||
.map(|(l, (coverage, hud))| {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world, coverage, hud) =
|
||||
(view(&frame), view(&world), view(coverage), view(hud));
|
||||
let entry = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let params = wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: l.params.as_entire_binding(),
|
||||
};
|
||||
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR layered bindings"),
|
||||
layout,
|
||||
entries,
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
&[
|
||||
entry(0, &frame),
|
||||
params,
|
||||
entry(4, &coverage),
|
||||
entry(5, &hud),
|
||||
],
|
||||
),
|
||||
]
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
coverage,
|
||||
hud,
|
||||
group,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
if let Some(l) = &self.layered {
|
||||
queue.write_buffer(
|
||||
&l.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
};
|
||||
let Some([group, ..]) = &t.passes else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.resolve.1,
|
||||
group,
|
||||
&view,
|
||||
"HDR marks to light",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
|
||||
pub fn composite(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if let (
|
||||
Some(l),
|
||||
Some(Targets {
|
||||
passes: Some([_, _, group]),
|
||||
..
|
||||
}),
|
||||
) = (&self.layered, &self.targets)
|
||||
{
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.composite.1,
|
||||
group,
|
||||
target,
|
||||
"HDR composite",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
|
||||
Some(
|
||||
self.targets
|
||||
.as_ref()?
|
||||
.coverage
|
||||
.as_ref()?
|
||||
.create_view(&Default::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
@@ -394,6 +610,9 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
///
|
||||
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
|
||||
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -402,6 +621,11 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
|
||||
let view = hud.create_view(&Default::default());
|
||||
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
|
||||
return;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
@@ -429,13 +653,14 @@ impl Hdr {
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
|
||||
/// so it needs no mark there.
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
@@ -466,9 +691,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
/// reads for it too), and what tints the world rather than covering it. None on the
|
||||
/// layered frame, where both draw on the frame before `keep_world`.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
@@ -494,7 +720,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
let view = target.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.pipeline,
|
||||
&t.group,
|
||||
&view,
|
||||
"HDR output",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -517,38 +751,66 @@ impl Hdr {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR screenshot bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.frame)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view(&t.world)),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&t.frame,
|
||||
&t.world,
|
||||
t.coverage.as_ref(),
|
||||
t.hud.as_ref(),
|
||||
&self.capture_params,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&self.capture_params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.capture_pipeline,
|
||||
&group,
|
||||
&view,
|
||||
"HDR output",
|
||||
None,
|
||||
);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
fn group(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
frame: &wgpu::Texture,
|
||||
world: &wgpu::Texture,
|
||||
coverage: Option<&wgpu::Texture>,
|
||||
hud: Option<&wgpu::Texture>,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world) = (view(frame), view(world));
|
||||
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
|
||||
let texture = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0, &frame),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
texture(3, &self.logo),
|
||||
];
|
||||
match &layered {
|
||||
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
|
||||
None => entries.push(texture(1, &world)),
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
@@ -577,16 +839,17 @@ impl Hdr {
|
||||
params
|
||||
}
|
||||
|
||||
/// One full-screen pass of `pipeline` into `view`.
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
view: &wgpu::TextureView,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -595,8 +858,8 @@ impl Hdr {
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -655,9 +918,279 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, named, pick};
|
||||
use super::{Hdr, Output, named, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for enabled in [false, true] {
|
||||
for debug in [None, Some("coverage")] {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, enabled, debug);
|
||||
hdr.frame(&device, [2, 1]);
|
||||
assert_eq!(hdr.coverage_view().is_some(), enabled);
|
||||
hdr.frame(&device, [4, 2]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
if let Some(coverage) = &t.coverage {
|
||||
assert_eq!(coverage.size(), t.frame.size());
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame_view = t.frame.create_view(&Default::default());
|
||||
let mut color = super::super::hud_coverage::attachment(&frame_view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::BLACK);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
if let Some(view) = hdr.coverage_view() {
|
||||
super::super::hud_coverage::clear(&mut encoder, &view);
|
||||
let mut color = super::super::hud_coverage::attachment(&view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::WHITE);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, false).unwrap();
|
||||
let buffer = super::super::copy_frame(&device, &mut encoder, &capture, [4, 2]);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
let [r, g, b] = [0, 10, 20].map(|shift| (pixel >> shift) & 0x3ff);
|
||||
if enabled && debug.is_some() {
|
||||
// Fully covered: BT.709 magenta at paper white (200 nits here) in BT.2020
|
||||
// PQ, not PQ's 10 000 nits (1023, 0, 1023).
|
||||
let near = |got: u32, want: u32| got.abs_diff(want) <= 4;
|
||||
assert!(near(r, 550) && near(g, 346) && near(b, 583), "{r} {g} {b}");
|
||||
} else {
|
||||
assert_eq!(pixel, 0xc0000000);
|
||||
}
|
||||
if let Some(view) = hdr.coverage_view() {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
super::super::hud_coverage::clear(&mut encoder, &view);
|
||||
let coverage = hdr.targets.as_ref().unwrap().coverage.as_ref().unwrap();
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 512,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
coverage.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
coverage.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert_eq!(&buffer.get_mapped_range(..).unwrap()[..4], &[0; 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut attachment = super::super::hud_coverage::attachment(&view);
|
||||
let [r, g, b, a] = color;
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(attachment)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
mut encoder: wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> [f32; 4] {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
if texture.format() == Output::Pq.format() {
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
// What the last pass shows of a world pixel nothing covers, from today's frame
|
||||
// (`layered` false) or the layered one, whose marks turn into light before it.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, world);
|
||||
if layered {
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
texel(&device, &queue, encoder, &capture)
|
||||
};
|
||||
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
|
||||
for world in [
|
||||
[0.5, 0.4, 0.3, 1.0],
|
||||
[1.5, 1.2, 0.6, 1.0],
|
||||
[0.8, 0.7, 0.6, 0.88],
|
||||
[0.9, 0.8, 0.4, 0.05],
|
||||
[0.9, 0.8, 0.4, 0.2],
|
||||
[0.9, 0.8, 0.4, 0.4],
|
||||
[0.9, 0.8, 0.4, 0.6],
|
||||
] {
|
||||
let (today, layered) = (shown(false, world), shown(true, world));
|
||||
// Half floats round the light apart by a step at most.
|
||||
assert!(
|
||||
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
|
||||
"{world:?}: {today:?} {layered:?}"
|
||||
);
|
||||
if world[3] < 1.0 {
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: F::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// The world's light, the HUD's target over it (on the world clipped to white), its
|
||||
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
|
||||
for (world, hud, coverage, ui, want) in [
|
||||
// Nothing over it: the world, light above white included.
|
||||
(3.0, 1.0, 0.0, 1.0, 3.0),
|
||||
// Half-transparent black: half of the world shows, as the HUD blends.
|
||||
(2.0, 0.5, 0.5, 1.0, 1.0),
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world, world, world, 1.0]);
|
||||
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
assert!(
|
||||
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
|
||||
"{world} {hud} {coverage} {ui}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let size = [8, 8];
|
||||
let frame = hdr.frame(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
|
||||
let (_, source) = blur.source(&device, size);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &source, None);
|
||||
let view = frame.create_view(&Default::default());
|
||||
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
let hud = hdr.hud().unwrap().clone();
|
||||
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
|
||||
let [r, ..] = texel(&device, &queue, encoder, &frame);
|
||||
assert!((r - 4.0).abs() < 0.01, "{r}");
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
|
||||
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
|
||||
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
|
||||
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
|
||||
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
|
||||
//! screen yet (terrain streaming in) are not held.
|
||||
//!
|
||||
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
|
||||
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
|
||||
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
|
||||
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
|
||||
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
|
||||
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
|
||||
use super::SectionKey;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The longest a hold lasts.
|
||||
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
|
||||
/// The most bytes of new meshes that wait.
|
||||
pub(super) const MAX_BYTES: u64 = 1 << 30;
|
||||
/// The most circles a hold is kept to (`WorldHold::hold_within`).
|
||||
pub const MAX_AREAS: usize = 64;
|
||||
|
||||
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
|
||||
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
|
||||
/// holds all of it, and circles join.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldHold {
|
||||
all: bool,
|
||||
within: Vec<[f64; 3]>,
|
||||
}
|
||||
impl WorldHold {
|
||||
/// Hold the whole world's terrain as it is drawn, this frame.
|
||||
pub fn hold(&mut self) {
|
||||
self.all = true;
|
||||
}
|
||||
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
|
||||
/// circle that isn't finite or has a negative radius is left out, as are those past
|
||||
/// `MAX_AREAS`.
|
||||
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
|
||||
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
|
||||
if good && self.within.len() < MAX_AREAS {
|
||||
self.within.push([x, z, radius]);
|
||||
}
|
||||
}
|
||||
/// Whether anything is held.
|
||||
pub fn is_held(&self) -> bool {
|
||||
self.all || !self.within.is_empty()
|
||||
}
|
||||
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
|
||||
pub fn covers(&self, key: SectionKey) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
|
||||
self.within
|
||||
.iter()
|
||||
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a frame's ask means for the sections waiting.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Release {
|
||||
/// They go on waiting.
|
||||
None,
|
||||
/// Those the hold no longer covers take their place.
|
||||
Outside,
|
||||
/// All of them take their place.
|
||||
All,
|
||||
}
|
||||
|
||||
/// A section waiting behind the hold and the bytes its new mesh takes.
|
||||
struct Waiting<S> {
|
||||
section: S,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
|
||||
pub(super) struct Hold<S> {
|
||||
ask: WorldHold,
|
||||
since: Option<Instant>,
|
||||
spent: bool,
|
||||
waiting: FxHashMap<SectionKey, Waiting<S>>,
|
||||
bytes: u64,
|
||||
}
|
||||
impl<S> Default for Hold<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ask: WorldHold::default(),
|
||||
since: None,
|
||||
spent: false,
|
||||
waiting: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<S> Hold<S> {
|
||||
/// Whether a new mesh of section `key` waits.
|
||||
pub fn holds(&self, key: SectionKey) -> bool {
|
||||
self.since.is_some() && !self.spent && self.ask.covers(key)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.waiting.is_empty()
|
||||
}
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.bytes
|
||||
}
|
||||
/// A frame's ask, at `now`.
|
||||
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
|
||||
if !ask.is_held() {
|
||||
let was = self.since.is_some();
|
||||
(self.since, self.spent) = (None, false);
|
||||
self.ask = WorldHold::default();
|
||||
return if was && !self.waiting.is_empty() {
|
||||
Release::All
|
||||
} else {
|
||||
Release::None
|
||||
};
|
||||
}
|
||||
if self.spent {
|
||||
return Release::None;
|
||||
}
|
||||
let since = *self.since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= MAX_HOLD {
|
||||
log::warn!(
|
||||
"Horizon: a mod held the world for {} s: let go",
|
||||
MAX_HOLD.as_secs()
|
||||
);
|
||||
self.spend();
|
||||
return Release::All;
|
||||
}
|
||||
if *ask == self.ask {
|
||||
return Release::None;
|
||||
}
|
||||
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
|
||||
self.ask.clone_from(ask);
|
||||
if self.waiting.is_empty() {
|
||||
Release::None
|
||||
} else {
|
||||
Release::Outside
|
||||
}
|
||||
}
|
||||
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
|
||||
/// What waited is the caller's to place (`take_all`) or drop.
|
||||
pub fn spend(&mut self) {
|
||||
self.spent = self.since.is_some();
|
||||
}
|
||||
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
|
||||
/// The bool is whether what waits is now too much (`MAX_BYTES`).
|
||||
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
|
||||
let old = self.take(key);
|
||||
self.bytes += bytes;
|
||||
self.waiting.insert(key, Waiting { section, bytes });
|
||||
(old, self.bytes > MAX_BYTES)
|
||||
}
|
||||
/// Section `key`'s waiting mesh, no longer waiting.
|
||||
pub fn take(&mut self, key: SectionKey) -> Option<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = self.waiting.remove(&key)?;
|
||||
self.bytes -= w.bytes;
|
||||
Some(w.section)
|
||||
}
|
||||
/// Everything waiting, no longer waiting.
|
||||
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
|
||||
self.bytes = 0;
|
||||
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits that the hold no longer covers.
|
||||
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
|
||||
let ask = &self.ask;
|
||||
let all = self.since.is_none() || self.spent;
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|&k, _| all || !ask.covers(k))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits in these columns, which are going.
|
||||
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(_, w)| w.section).collect()
|
||||
}
|
||||
/// The sections are all gone: what waits goes with them and the hold is spent.
|
||||
pub fn clear(&mut self) {
|
||||
self.waiting.clear();
|
||||
self.bytes = 0;
|
||||
self.spend();
|
||||
}
|
||||
pub fn sections(&self) -> impl Iterator<Item = &S> {
|
||||
self.waiting.values().map(|w| &w.section)
|
||||
}
|
||||
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
|
||||
self.waiting.values_mut().map(|w| &mut w.section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all() -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
h.hold();
|
||||
h
|
||||
}
|
||||
fn within(circles: &[[f64; 3]]) -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
for &[x, z, r] in circles {
|
||||
h.hold_within(x, z, r);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_covers_sections_by_their_middle() {
|
||||
assert!(all().covers((900, 0, -900)));
|
||||
assert!(!WorldHold::default().covers((0, 0, 0)));
|
||||
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
|
||||
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
|
||||
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
|
||||
}
|
||||
#[test]
|
||||
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
|
||||
let h = within(&[
|
||||
[f64::NAN, 0.0, 5.0],
|
||||
[0.0, f64::INFINITY, 5.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
[0.0, 0.0, f64::INFINITY],
|
||||
]);
|
||||
assert!(!h.is_held());
|
||||
let mut many = WorldHold::default();
|
||||
for i in 0..MAX_AREAS + 10 {
|
||||
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
|
||||
}
|
||||
assert_eq!(many.within.len(), MAX_AREAS);
|
||||
// (several mods: all of it from one holds all of it)
|
||||
many.hold();
|
||||
assert!(many.covers((-5000, 0, 5000)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_starts_and_lets_go_when_no_one_asks() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
|
||||
// (nothing waited: nothing to place)
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 7, 100);
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
|
||||
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
|
||||
g.ask(&all(), t + MAX_HOLD * 3);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn smaller_circles_let_go_what_they_leave() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
|
||||
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((5, 0, 0), 2, 20);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
|
||||
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
assert!(!g.holds((5, 0, 0)));
|
||||
// (to all of it again: nothing more is let go, and all of it waits again)
|
||||
assert_eq!(g.ask(&all(), t), Release::Outside);
|
||||
assert!(g.take_outside().is_empty());
|
||||
assert!(g.holds((5, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn what_waits_is_counted_and_capped() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
|
||||
// (a newer mesh of the same section replaces it, and the old one comes back to free)
|
||||
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 20);
|
||||
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
|
||||
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
|
||||
assert_eq!(g.take((1, 0, 0)), Some(3));
|
||||
assert_eq!(g.take((1, 0, 0)), None);
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 19);
|
||||
}
|
||||
#[test]
|
||||
fn columns_going_and_the_sections_cleared_drop_what_waits() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((0, 5, 0), 2, 10);
|
||||
g.wait((3, 0, 0), 3, 10);
|
||||
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
|
||||
let mut dropped = g.take_columns(&gone);
|
||||
dropped.sort_unstable();
|
||||
assert_eq!(dropped, vec![1, 2]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
g.clear();
|
||||
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
|
||||
// (cleared while held: spent until let go; with no hold, nothing to end)
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(!g.holds((3, 0, 0)));
|
||||
let mut none = Hold::<u32>::default();
|
||||
none.clear();
|
||||
none.ask(&all(), t);
|
||||
assert!(none.holds((3, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
|
||||
|
||||
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
|
||||
pub(crate) fn supported(
|
||||
lane: (bool, bool, bool),
|
||||
hdr_offered: bool,
|
||||
attachments: u32,
|
||||
flags: wgpu::DownlevelFlags,
|
||||
format: wgpu::TextureFormatFeatures,
|
||||
) -> bool {
|
||||
!lane.0
|
||||
&& lane.1
|
||||
&& lane.2
|
||||
&& hdr_offered
|
||||
&& attachments >= 2
|
||||
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
|
||||
&& format
|
||||
.allowed_usages
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING)
|
||||
&& format
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
}
|
||||
|
||||
pub(crate) fn attachment(view: &wgpu::TextureView) -> wgpu::RenderPassColorAttachment<'_> {
|
||||
wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView) {
|
||||
let mut color = attachment(view);
|
||||
color.ops.load = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD coverage reset"),
|
||||
color_attachments: &[Some(color)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) struct Pipeline {
|
||||
plain: wgpu::RenderPipeline,
|
||||
coverage: Option<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
/// `None` when `enabled` and the shader can't be given a coverage output (a resource
|
||||
/// pack's, which the caller then doesn't use).
|
||||
pub(crate) fn new(
|
||||
device: &wgpu::Device,
|
||||
desc: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
source: wgpu::ShaderSource<'_>,
|
||||
enabled: bool,
|
||||
) -> Option<Self> {
|
||||
let coverage = if enabled {
|
||||
Some(pipeline(device, desc, source)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
plain: device.create_render_pipeline(desc),
|
||||
coverage,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, coverage: bool) -> &wgpu::RenderPipeline {
|
||||
if coverage {
|
||||
self.coverage.as_ref().expect("HUD coverage pipeline")
|
||||
} else {
|
||||
&self.plain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
|
||||
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
|
||||
/// `None` for a fragment shader without a colour output at location 0.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
desc: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
source: wgpu::ShaderSource<'_>,
|
||||
) -> Option<wgpu::RenderPipeline> {
|
||||
let mut desc = desc.clone();
|
||||
let fragment = desc.fragment.as_mut()?;
|
||||
let mut module = match source {
|
||||
wgpu::ShaderSource::Wgsl(s) => naga::front::wgsl::parse_str(&s).ok()?,
|
||||
wgpu::ShaderSource::Naga(m) => m.into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let primary = fragment.targets.first()?.clone()?;
|
||||
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
|
||||
let additive = matches!(
|
||||
(blend.src_factor, blend.dst_factor),
|
||||
(
|
||||
wgpu::BlendFactor::One
|
||||
| wgpu::BlendFactor::Src
|
||||
| wgpu::BlendFactor::SrcAlpha
|
||||
| wgpu::BlendFactor::Zero,
|
||||
wgpu::BlendFactor::One
|
||||
)
|
||||
) && blend.operation == wgpu::BlendOperation::Add;
|
||||
wrap(&mut module, fragment.entry_point?, blend)?;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: desc.label,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let over = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrc,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let targets = [
|
||||
Some(primary),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: over,
|
||||
alpha: over,
|
||||
}),
|
||||
write_mask: if additive {
|
||||
wgpu::ColorWrites::empty()
|
||||
} else {
|
||||
wgpu::ColorWrites::RED
|
||||
},
|
||||
}),
|
||||
];
|
||||
fragment.module = &shader;
|
||||
fragment.targets = &targets;
|
||||
Some(device.create_render_pipeline(&desc))
|
||||
}
|
||||
|
||||
fn wrap(module: &mut naga::Module, name: &str, blend: wgpu::BlendComponent) -> Option<()> {
|
||||
use naga::{Expression as E, Statement as S, TypeInner as T};
|
||||
use wgpu::BlendFactor as B;
|
||||
let span = naga::Span::UNDEFINED;
|
||||
let entry = module.entry_points.iter_mut().find(|e| e.name == name)?;
|
||||
// Checked before anything is taken apart: a resource pack's shader may have no colour.
|
||||
let result = entry.function.result.clone()?;
|
||||
if let T::Struct { members, .. } = &module.types[result.ty].inner {
|
||||
members
|
||||
.iter()
|
||||
.find(|m| matches!(m.binding, Some(naga::Binding::Location { location: 0, .. })))?;
|
||||
} else if !matches!(
|
||||
result.binding,
|
||||
Some(naga::Binding::Location { location: 0, .. })
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let mut original = std::mem::take(&mut entry.function);
|
||||
entry.function.arguments = original.arguments.clone();
|
||||
for arg in &mut original.arguments {
|
||||
arg.binding = None;
|
||||
}
|
||||
original.result.as_mut().unwrap().binding = None;
|
||||
original.name = Some("hud_color".into());
|
||||
let function = module.functions.append(original, span);
|
||||
let f = &mut entry.function;
|
||||
let args = (0..f.arguments.len())
|
||||
.map(|i| f.expressions.append(E::FunctionArgument(i as u32), span))
|
||||
.collect();
|
||||
let call = f.expressions.append(E::CallResult(function), span);
|
||||
f.body.push(
|
||||
S::Call {
|
||||
function,
|
||||
arguments: args,
|
||||
result: Some(call),
|
||||
},
|
||||
span,
|
||||
);
|
||||
let one = f
|
||||
.expressions
|
||||
.append(E::Literal(naga::Literal::F32(1.0)), span);
|
||||
let start = f.expressions.len();
|
||||
let (mut members, mut values, color, offset) = match &module.types[result.ty].inner {
|
||||
T::Struct {
|
||||
members,
|
||||
span: size,
|
||||
} => {
|
||||
// HUD pipelines have one target, so a pack's other colour outputs went nowhere;
|
||||
// left in, one at location 1 would collide with the coverage.
|
||||
let (members, values): (Vec<_>, Vec<_>) = members
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
!matches!(m.binding, Some(naga::Binding::Location { location, .. }) if location != 0)
|
||||
})
|
||||
.map(|(i, m)| {
|
||||
let value = f.expressions.append(
|
||||
E::AccessIndex {
|
||||
base: call,
|
||||
index: i as u32,
|
||||
},
|
||||
span,
|
||||
);
|
||||
(m.clone(), value)
|
||||
})
|
||||
.unzip();
|
||||
let color = members
|
||||
.iter()
|
||||
.position(|m| {
|
||||
matches!(m.binding, Some(naga::Binding::Location { location: 0, .. }))
|
||||
})
|
||||
.unwrap();
|
||||
let color = values[color];
|
||||
(members, values, color, *size)
|
||||
}
|
||||
_ => (
|
||||
vec![naga::StructMember {
|
||||
name: Some("color".into()),
|
||||
ty: result.ty,
|
||||
binding: result.binding,
|
||||
offset: 0,
|
||||
}],
|
||||
vec![call],
|
||||
call,
|
||||
16,
|
||||
),
|
||||
};
|
||||
let mut component = |index| {
|
||||
f.expressions
|
||||
.append(E::AccessIndex { base: color, index }, span)
|
||||
};
|
||||
let [r, g, b, a] = [component(0), component(1), component(2), component(3)];
|
||||
let mut math = |fun, arg, arg1| {
|
||||
f.expressions.append(
|
||||
E::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1: Some(arg1),
|
||||
arg2: None,
|
||||
arg3: None,
|
||||
},
|
||||
span,
|
||||
)
|
||||
};
|
||||
let low = math(naga::MathFunction::Min, r, g);
|
||||
let low = math(naga::MathFunction::Min, low, b);
|
||||
let high = math(naga::MathFunction::Max, r, g);
|
||||
let high = math(naga::MathFunction::Max, high, b);
|
||||
// A scalar cannot reproduce RGB-dependent replacement or inversion: surviving pixels
|
||||
// cover fully, so the later composite cannot leak world light through them.
|
||||
let c = if blend.operation != wgpu::BlendOperation::Add {
|
||||
one
|
||||
} else {
|
||||
match (blend.src_factor, blend.dst_factor) {
|
||||
(B::SrcAlpha | B::One, B::OneMinusSrcAlpha) => a,
|
||||
(B::Zero, B::OneMinusSrc) => high,
|
||||
// Multiplying retains src.rgb (or 1 - alpha + src.rgb) of the backdrop.
|
||||
// Use the greatest channel darkening, as for the credits vignette above.
|
||||
(B::Zero, B::Src)
|
||||
| (B::Dst, B::Zero)
|
||||
| (B::Dst, B::OneMinusSrcAlpha)
|
||||
| (B::Dst, B::Src) => {
|
||||
let top = if blend.dst_factor == B::OneMinusSrcAlpha {
|
||||
a
|
||||
} else {
|
||||
one
|
||||
};
|
||||
let factor = if blend.dst_factor == B::Src && blend.src_factor == B::Dst {
|
||||
f.expressions.append(
|
||||
E::Binary {
|
||||
op: naga::BinaryOperator::Add,
|
||||
left: low,
|
||||
right: low,
|
||||
},
|
||||
span,
|
||||
)
|
||||
} else {
|
||||
low
|
||||
};
|
||||
f.expressions.append(
|
||||
E::Binary {
|
||||
op: naga::BinaryOperator::Subtract,
|
||||
left: top,
|
||||
right: factor,
|
||||
},
|
||||
span,
|
||||
)
|
||||
}
|
||||
_ => one,
|
||||
}
|
||||
};
|
||||
let c = f.expressions.append(
|
||||
E::Math {
|
||||
fun: naga::MathFunction::Saturate,
|
||||
arg: c,
|
||||
arg1: None,
|
||||
arg2: None,
|
||||
arg3: None,
|
||||
},
|
||||
span,
|
||||
);
|
||||
let scalar = module.types.insert(
|
||||
naga::Type {
|
||||
name: None,
|
||||
inner: T::Scalar(naga::Scalar::F32),
|
||||
},
|
||||
span,
|
||||
);
|
||||
members.push(naga::StructMember {
|
||||
name: Some("coverage".into()),
|
||||
ty: scalar,
|
||||
binding: Some(naga::Binding::Location {
|
||||
location: 1,
|
||||
interpolation: None,
|
||||
sampling: None,
|
||||
blend_src: None,
|
||||
per_primitive: false,
|
||||
}),
|
||||
offset,
|
||||
});
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: Some("HudCoverage".into()),
|
||||
inner: T::Struct {
|
||||
members,
|
||||
span: (offset + 4).next_multiple_of(16),
|
||||
},
|
||||
},
|
||||
span,
|
||||
);
|
||||
values.push(c);
|
||||
let value = f.expressions.append(
|
||||
E::Compose {
|
||||
ty,
|
||||
components: values,
|
||||
},
|
||||
span,
|
||||
);
|
||||
f.result = Some(naga::FunctionResult { ty, binding: None });
|
||||
f.body.push(S::Emit(f.expressions.range_from(start)), span);
|
||||
f.body.push(S::Return { value: Some(value) }, span);
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
|
||||
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
|
||||
let format = wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
|
||||
};
|
||||
let lane = (false, true, true);
|
||||
assert!(supported(lane, true, 2, flags, format));
|
||||
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
|
||||
// pack's float frame, and unsupported devices never allocate coverage.
|
||||
for other in [
|
||||
(false, false, true),
|
||||
(true, false, true),
|
||||
(true, true, true),
|
||||
(false, true, false),
|
||||
] {
|
||||
assert!(!supported(other, true, 2, flags, format));
|
||||
}
|
||||
assert!(!supported(lane, false, 2, flags, format));
|
||||
assert!(!supported(lane, true, 1, flags, format));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
wgpu::DownlevelFlags::empty(),
|
||||
format
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
flags: wgpu::TextureFormatFeatureFlags::empty(),
|
||||
..format
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
..format
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_coverage_builds_only_the_original_pipeline() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4(0.0); }".into(),
|
||||
),
|
||||
});
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: None,
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
};
|
||||
// The coverage source must not even be parsed outside the lane.
|
||||
let pipeline = Pipeline::new(
|
||||
&device,
|
||||
&desc,
|
||||
wgpu::ShaderSource::Wgsl("invalid".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pipeline.coverage.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
|
||||
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
|
||||
wrap(
|
||||
&mut module,
|
||||
"fs_debug_line",
|
||||
wgpu::BlendState::ALPHA_BLENDING.color,
|
||||
)
|
||||
.unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_replaces_a_colour_output_the_hud_has_no_target_for() {
|
||||
let mut module = naga::front::wgsl::parse_str(
|
||||
"struct Out { @location(0) color: vec4<f32>, @location(1) unused: vec4<f32> }
|
||||
@fragment fn fs() -> Out { return Out(vec4(0.5), vec4(1.0)); }",
|
||||
)
|
||||
.unwrap();
|
||||
wrap(&mut module, "fs", wgpu::BlendState::ALPHA_BLENDING.color).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
let result = module.entry_points[0].function.result.as_ref().unwrap();
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
panic!("the wrapped entry returns a struct");
|
||||
};
|
||||
let names: Vec<_> = members.iter().map(|m| m.name.as_deref().unwrap()).collect();
|
||||
assert_eq!(names, ["color", "coverage"]);
|
||||
}
|
||||
}
|
||||
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.base, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
|
||||
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
|
||||
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
|
||||
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
@@ -349,12 +351,13 @@ fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
(vertex, displaced): (bool, bool),
|
||||
api: horizon_shader_api::Api,
|
||||
) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
|
||||
vanilla_terrain(),
|
||||
super::native_post::line_helpers(api),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
@@ -378,8 +381,9 @@ fn module(
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
api: horizon_shader_api::Api,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
|
||||
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -403,6 +407,7 @@ impl NativeMaterial {
|
||||
shape.sprite_materials,
|
||||
vertex,
|
||||
displaced.is_some(),
|
||||
frame.api,
|
||||
)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
@@ -562,7 +567,7 @@ impl NativeMaterial {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
|
||||
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
|
||||
|
||||
fn stage(material: &str) -> CheckedStage {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
@@ -609,7 +614,7 @@ mod tests {
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false, false).unwrap();
|
||||
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -622,8 +627,8 @@ mod tests {
|
||||
&stage.source,
|
||||
&tags,
|
||||
&materials,
|
||||
false,
|
||||
false
|
||||
(false, false),
|
||||
Api::V0_1
|
||||
))
|
||||
)
|
||||
});
|
||||
@@ -647,6 +652,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
|
||||
#[test]
|
||||
fn the_terrain_validates_with_a_0_2_pack() {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
|
||||
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
|
||||
let material = "fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
|
||||
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
|
||||
return input.position + input.tint.rgb;\n}\n";
|
||||
let files = [
|
||||
(MANIFEST, manifest),
|
||||
("material.wgsl", material),
|
||||
("vertex.wgsl", vertex),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
|
||||
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_displaced_grid_validates_with_a_pack() {
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
@@ -661,14 +693,14 @@ mod tests {
|
||||
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report.stages.remove(0);
|
||||
let materials = [(5, 1000)];
|
||||
let module = module(&stage, &[], &materials, false, true).unwrap();
|
||||
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| {
|
||||
let text = shader_source(&stage.source, &[], &materials, false, true);
|
||||
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
|
||||
panic!("{}", e.emit_to_string(&text))
|
||||
});
|
||||
for entry in [
|
||||
@@ -681,4 +713,116 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
|
||||
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
|
||||
/// what 0.1's does (vanilla's) within an 8-bit step.
|
||||
#[test]
|
||||
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The entries' call of `material`, with a 16×16 grid of colours.
|
||||
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let c = floor(p.xy) / 15.0;
|
||||
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
|
||||
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
|
||||
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
|
||||
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
|
||||
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
|
||||
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
|
||||
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
";
|
||||
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let draw = |api: Api| {
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
|
||||
[material]\nfile = \"material.wgsl\"\n"
|
||||
);
|
||||
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let source = report.stages[0].source.clone()
|
||||
+ horizon_shader_api::stages::PBR_STUB
|
||||
+ super::super::native_post::line_helpers(api)
|
||||
+ entry;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let size = wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: Default::default(),
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
|
||||
queue.submit([encoder.finish()]);
|
||||
screenshot
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
|
||||
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,27 @@ impl NativePack {
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
// A backend may refuse a layout or pipeline the pack asks for (a stage's bindings
|
||||
// past its sampled-texture limit, say, as OpenGL's 16 was before #10095): the pack
|
||||
// is refused with that error instead of a validation panic in the renderer.
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let built = Self::build(device, queue, terrain, report, constants, lights);
|
||||
let refused = pollster::block_on(scope.pop());
|
||||
match (built, refused) {
|
||||
(Ok(built), None) => Ok(built),
|
||||
(Ok(_), Some(e)) => Err(e.to_string()),
|
||||
(Err(e), _) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
@@ -101,7 +122,15 @@ impl NativePack {
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let api = report.manifest.api;
|
||||
let mut frame = NativeFrame::new(
|
||||
device,
|
||||
settings.as_ref(),
|
||||
textures,
|
||||
lights,
|
||||
terrain.pbr,
|
||||
api,
|
||||
);
|
||||
// `[displacement]`, unless its option is off; nothing to displace without heights.
|
||||
let displacement = report
|
||||
.manifest
|
||||
@@ -222,8 +251,13 @@ impl NativePack {
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
|
||||
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
|
||||
.then(|| {
|
||||
let manifest = &report.manifest;
|
||||
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
|
||||
})
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
@@ -265,9 +299,90 @@ impl NativePack {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use super::{NativePack, TerrainShape, chosen_shadow};
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
/// A device with OpenGL's minimum 16 sampled textures per stage refuses a pack whose
|
||||
/// sky adds the terrain's 10 to the frame's 9: that is the pack's error to report, not
|
||||
/// a validation panic (#10095).
|
||||
#[test]
|
||||
fn a_pack_the_backend_refuses_is_an_error_not_a_panic() {
|
||||
use horizon_shader_api::{MANIFEST, Pack, check};
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let mut limits = adapter.limits();
|
||||
limits.max_sampled_textures_per_shader_stage = 16;
|
||||
let Ok((device, queue)) =
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The terrain's group 0: its uniform and the 10 sampled textures (_terrain.wgsl_'s
|
||||
// 8 sprite arrays, the lightmap and the emission thresholds).
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let mut entries = vec![entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
)];
|
||||
entries.extend((1..=10).map(|binding| {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
}));
|
||||
let globals = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &entries,
|
||||
});
|
||||
let empty = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &[],
|
||||
});
|
||||
let terrain = TerrainShape {
|
||||
globals: &globals,
|
||||
flat: None,
|
||||
sprites: [1.0, 2048.0],
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
sprite_tags: &[],
|
||||
sprite_materials: &[],
|
||||
entities: &empty,
|
||||
weather_groups: [&empty, &empty],
|
||||
pbr: None,
|
||||
};
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[sky]\nfile = \"sky.wgsl\"\n";
|
||||
let files = [
|
||||
(MANIFEST, manifest),
|
||||
(
|
||||
"sky.wgsl",
|
||||
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let built = NativePack::new(&device, &queue, &terrain, report, &[], Default::default());
|
||||
assert!(built.is_err(), "the pack should be refused");
|
||||
}
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
|
||||
pub normals: &'a wgpu::TextureView,
|
||||
}
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
|
||||
fn shadow_color(api: Api) -> wgpu::TextureFormat {
|
||||
match api {
|
||||
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
|
||||
Api::V0_2 => super::native_post::INTERMEDIATE,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
@@ -218,7 +223,7 @@ pub struct ShadowMap {
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -250,7 +255,7 @@ impl ShadowMap {
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
shadow_color(api),
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
@@ -474,6 +479,7 @@ fn shader_source(
|
||||
vertex: Option<&str>,
|
||||
materials: &[(u32, u32)],
|
||||
grid: Option<&str>,
|
||||
api: Api,
|
||||
) -> String {
|
||||
let table = super::native_material::layer_table("horizon_sprite_material", materials);
|
||||
let (head, displace) = match (grid, vertex) {
|
||||
@@ -504,11 +510,12 @@ fn shader_source(
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,6 +534,7 @@ impl NativeShadow {
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
grid.as_ref().map(|g| g.material.source.as_str()),
|
||||
frame.api,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
@@ -613,7 +621,7 @@ impl NativeShadow {
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
format: shadow_color(frame.api),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
@@ -731,8 +739,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
|
||||
let lines = [Api::V0_1, Api::V0_2];
|
||||
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
@@ -767,6 +776,7 @@ mod tests {
|
||||
None,
|
||||
&materials,
|
||||
Some(&stage.source),
|
||||
Api::V0_1,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
|
||||
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
|
||||
const PACK_ENTRY: &str = "
|
||||
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = horizon_sky_view_ray(in.ndc);
|
||||
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
|
||||
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
|
||||
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
|
||||
}
|
||||
|
||||
/// The sky shader for the pack's sky stage `source`.
|
||||
fn shader_source(source: &str) -> String {
|
||||
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
|
||||
vanilla_sky()
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
|
||||
vanilla_sky(),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +101,7 @@ fn build(
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
|
||||
let text = shader_source(&stage.source);
|
||||
let text = shader_source(&stage.source, frame.api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
@@ -195,7 +197,7 @@ mod tests {
|
||||
// A pack may use the renderer's names for its own things.
|
||||
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
|
||||
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
|
||||
let text = shader_source(&stage(own).source);
|
||||
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
|
||||
panic!("{}", e.emit_to_string(&text));
|
||||
});
|
||||
@@ -252,7 +254,14 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
horizon_shader_api::Api::V0_1,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -728,6 +728,13 @@ impl PackAtlas {
|
||||
),
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
|
||||
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
|
||||
// face shines (`mesher::WHOLE`).
|
||||
(
|
||||
"horizonTier",
|
||||
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
|
||||
),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
|
||||
|
||||
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
flat,
|
||||
geometry,
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
let expected = [
|
||||
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
assert_eq!(count, 98);
|
||||
}
|
||||
|
||||
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
|
||||
/// read by a pack's terrain program, flat or instanced.
|
||||
#[test]
|
||||
fn terrain_tier_reaches_pack_pixels() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
|
||||
Ok(adapter) => adapter,
|
||||
Err(error) => {
|
||||
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let folder = ProbePack(
|
||||
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
|
||||
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.fsh"),
|
||||
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
|
||||
// No light, low, very bright, and bright all over (4 more).
|
||||
let cases = [
|
||||
(0, 0.0),
|
||||
(LOW, 1.0),
|
||||
(VERY_BRIGHT, 4.0),
|
||||
(BRIGHT | WHOLE, 7.0),
|
||||
];
|
||||
for flat in [false, true] {
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let program = post
|
||||
.terrain
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{:?}", post.notes));
|
||||
for (tier, expected) in cases {
|
||||
let origin = section_instance([0.0; 3], 0, 1.0);
|
||||
let pixel = sample(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
program.program.as_ref().unwrap(),
|
||||
flat,
|
||||
false,
|
||||
origin,
|
||||
emitting(0xffff_ffff, tier),
|
||||
);
|
||||
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -190,6 +281,7 @@ fn sample(
|
||||
flat: bool,
|
||||
geometry: bool,
|
||||
origin: [f32; 4],
|
||||
color: u32,
|
||||
) -> [f32; 4] {
|
||||
let buffer = |bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -209,7 +301,7 @@ fn sample(
|
||||
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
|
||||
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
|
||||
.into_iter()
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
|
||||
.collect();
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
|
||||
@@ -217,6 +217,41 @@ impl CenterDepth {
|
||||
})
|
||||
}
|
||||
|
||||
/// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as
|
||||
/// the next frame reads it: an error unless exactly that one sample was read back, never
|
||||
/// an older one.
|
||||
pub fn take_completed(&mut self, device: &wgpu::Device) -> Result<f32, String> {
|
||||
device
|
||||
.poll(wgpu::PollType::Poll)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let state = |s: &Slot| s.state.load(Ordering::Acquire);
|
||||
let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED);
|
||||
let (Some(slot), None) = (mapped.next(), mapped.next()) else {
|
||||
return Err("the frame's centre depth wasn't read back alone".into());
|
||||
};
|
||||
if self
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| !matches!(state(s), FREE | MAPPED))
|
||||
{
|
||||
return Err("the frame's centre depth wasn't read back alone".into());
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]))
|
||||
.map_err(|e| e.to_string());
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
let depth = depth?;
|
||||
if !depth.is_finite() {
|
||||
return Err("the centre depth isn't finite".into());
|
||||
}
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
Ok(depth.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
@@ -265,6 +300,76 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Inline and on the submit thread: each finished frame's depth, then none left to
|
||||
/// take; several outstanding samples are refused.
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU"]
|
||||
fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() {
|
||||
let (device, raw) = test_device().expect("host GPU required");
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 3,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = depth.create_view(&Default::default());
|
||||
for threaded in [false, true] {
|
||||
let queue = super::super::submit::Queue::new(raw.clone(), threaded);
|
||||
if threaded {
|
||||
assert!(queue.threaded(), "deferred submit unavailable");
|
||||
}
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert!(center.take_completed(&device).is_err());
|
||||
for value in [0.125, 0.75, 0.25] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
clear(&mut encoder, &view, value);
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
let after = center.submitted().expect("one copied sample");
|
||||
if threaded {
|
||||
let job = super::super::submit::Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after: Some(Box::new(after)),
|
||||
};
|
||||
assert!(queue.submit_later(job).is_ok());
|
||||
queue.wait();
|
||||
} else {
|
||||
queue.submit([encoder.finish()]);
|
||||
after();
|
||||
}
|
||||
device
|
||||
.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(center.take_completed(&device).unwrap(), value);
|
||||
assert_eq!(center.collect(&device), Some(value));
|
||||
assert!(
|
||||
center.take_completed(&device).is_err(),
|
||||
"no stale latest fallback"
|
||||
);
|
||||
}
|
||||
// Several outstanding copies are ambiguous, even after they all map.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
for _ in 0..SLOTS {
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted().unwrap()();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert!(center.take_completed(&device).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames that go to the submit thread start their readback there: the centre depth
|
||||
/// follows the frames instead of keeping the first ones' depth.
|
||||
#[test]
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
/// What `gbuffers_entities` and `gbuffers_block` read from an entity vertex: camera-relative
|
||||
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
|
||||
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
|
||||
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
|
||||
// bright.
|
||||
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -454,9 +458,31 @@ impl PackEntities {
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
// Keep it in its own material even when a shader pack draws the
|
||||
// model in the same block-entity run. Do not corrupt either UV space.
|
||||
let whole: &[Vertex] = if whole
|
||||
.chunks(4)
|
||||
.any(|q| super::is_native_terrain(q[0].layer))
|
||||
{
|
||||
without_fluids.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
if super::is_native_terrain(quad[0].layer) {
|
||||
if !span.hidden {
|
||||
rest.extend_from_slice(quad);
|
||||
}
|
||||
} else {
|
||||
without_fluids.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
&without_fluids
|
||||
} else {
|
||||
whole
|
||||
};
|
||||
// A block entity's run can hold End portal faces and beams beside plain parts (an End
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
@@ -1141,6 +1167,83 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
|
||||
mod gpu_span_tests {
|
||||
use super::*;
|
||||
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn native_fluid_stays_native_while_model_uses_pack() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
for overlay in [
|
||||
super::super::FLUID_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_SOLID_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_CUTOUT_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
|
||||
] {
|
||||
let mut mixed = vec![
|
||||
Vertex {
|
||||
layer: 0,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
8
|
||||
];
|
||||
for v in &mut mixed[4..] {
|
||||
v.layer = overlay | 279 << 16;
|
||||
v.uv = [0.25, 0.75];
|
||||
v.glint_uv = [17.0, 0.0];
|
||||
}
|
||||
let mut rest = Vec::new();
|
||||
for hidden in [false, true] {
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&mixed,
|
||||
&[EntitySpan {
|
||||
start: 0,
|
||||
id: 77,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
hidden,
|
||||
..Default::default()
|
||||
}],
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(
|
||||
geometry.scratch.len(),
|
||||
4,
|
||||
"the model still uses the shader pack"
|
||||
);
|
||||
assert_eq!(geometry.draws.len(), 1);
|
||||
assert_eq!(
|
||||
(geometry.draws[0].pack, geometry.draws[0].shadow),
|
||||
(!hidden, true)
|
||||
);
|
||||
if hidden {
|
||||
assert!(
|
||||
rest.is_empty(),
|
||||
"hidden native fluid must not leak into the visible pass"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&rest),
|
||||
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
|
||||
"fluid UV/layer payload cannot be sampled from the entity atlas"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
|
||||
let mut skin = SkinFrame {
|
||||
@@ -1529,3 +1632,44 @@ mod velocity_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tier_translate_tests {
|
||||
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
|
||||
#[test]
|
||||
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
|
||||
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
|
||||
assert!(attribute.is_some(), "{who}");
|
||||
horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
|
||||
}
|
||||
|
||||
impl PackImage {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.view = None;
|
||||
self.copy = None;
|
||||
self.before = None;
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
|
||||
self.format
|
||||
.sample_type(None, Some(features))
|
||||
@@ -316,6 +323,11 @@ impl StandIn {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
|
||||
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
|
||||
*images = stand_ins(device, wanted);
|
||||
}
|
||||
|
||||
/// One stand-in for each format and dimension of `wanted`.
|
||||
pub(super) fn stand_ins(
|
||||
device: &wgpu::Device,
|
||||
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.group = None;
|
||||
self.buffers.clear();
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
|
||||
/// `minimums` long.
|
||||
pub(super) fn new(
|
||||
|
||||
@@ -149,6 +149,7 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
|
||||
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod history_reset_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
@@ -43,6 +46,9 @@ mod higher_shadowcolor_tests;
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixed_size_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_disc_blend_tests;
|
||||
|
||||
@@ -70,6 +76,9 @@ mod custom_type_tests;
|
||||
#[cfg(test)]
|
||||
mod sky_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod end_fan_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_length_tests;
|
||||
|
||||
@@ -372,12 +381,13 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass's pipeline: a draw, or a compute dispatch.
|
||||
/// A pass's pipeline: a draw, or a compute dispatch. Its slot is filled when the pack's
|
||||
/// pipelines are compiled (`compile`), all of them at once once the pack is laid out.
|
||||
#[derive(Clone)]
|
||||
enum Pipeline {
|
||||
Render(wgpu::RenderPipeline),
|
||||
Render(Slot<wgpu::RenderPipeline>),
|
||||
Compute {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
pipeline: Slot<wgpu::ComputePipeline>,
|
||||
/// `workGroups`, else `workGroupsRender` and the local size.
|
||||
groups: Option<[u32; 3]>,
|
||||
render: [f32; 2],
|
||||
@@ -392,10 +402,12 @@ struct Pass {
|
||||
pipeline: Pipeline,
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
|
||||
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
/// Shader-declared defaults must survive a reset of the mutable upload block.
|
||||
initial_block: Vec<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
@@ -449,9 +461,19 @@ struct Pass {
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
/// Back to the uniform values the pass was made with, and no bind groups.
|
||||
fn reset_history(&mut self) {
|
||||
self.block.clone_from(&self.initial_block);
|
||||
self.groups.clear();
|
||||
self.portal_groups.clear();
|
||||
for compute in &mut self.compute {
|
||||
compute.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> &wgpu::RenderPipeline {
|
||||
match &self.pipeline {
|
||||
Pipeline::Render(p) => p,
|
||||
Pipeline::Render(p) => p.get().expect("compiled with its pack"),
|
||||
Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
|
||||
}
|
||||
}
|
||||
@@ -494,6 +516,7 @@ struct Targets {
|
||||
}
|
||||
|
||||
/// What a pack's gbuffers programs read and draw from the game's world.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
@@ -1222,6 +1245,7 @@ fn block_ids(
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
/// (flat.rs), the layout of their bind group 1.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Geometry<'a> {
|
||||
pub buffers: &'a [Option<wgpu::VertexBufferLayout<'a>>],
|
||||
pub table: Option<&'a wgpu::BindGroupLayout>,
|
||||
@@ -1265,6 +1289,177 @@ struct Resources<'a> {
|
||||
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
|
||||
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
|
||||
shadow_linear: u8,
|
||||
/// While the pack is laid out for `compile`: the pipelines its programs need. `None`
|
||||
/// compiles each as its program is built.
|
||||
jobs: Option<std::cell::RefCell<Vec<Job>>>,
|
||||
}
|
||||
|
||||
impl Resources<'_> {
|
||||
/// The slot of the render pipeline `job` makes: filled now, or by `compile` when the pack
|
||||
/// is laid out.
|
||||
fn render_pipeline(&self, device: &wgpu::Device, job: RenderJob) -> Slot<wgpu::RenderPipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Render(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
/// As `render_pipeline`, for a compute pipeline.
|
||||
fn compute_pipeline(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
job: ComputeJob,
|
||||
) -> Slot<wgpu::ComputePipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Compute(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
}
|
||||
|
||||
/// A pipeline, made once: `Pipeline`'s, filled by its `Job`.
|
||||
type Slot<T> = std::sync::Arc<std::sync::OnceLock<T>>;
|
||||
|
||||
/// What a render pipeline of a pack's program is made of, kept to make it later.
|
||||
#[derive(Clone)]
|
||||
struct RenderJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
vs: wgpu::ShaderModule,
|
||||
fs: wgpu::ShaderModule,
|
||||
/// The vertex buffers: stride, step mode and attributes.
|
||||
buffers: Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>>,
|
||||
primitive: wgpu::PrimitiveState,
|
||||
depth_stencil: Option<wgpu::DepthStencilState>,
|
||||
targets: Vec<Option<wgpu::ColorTargetState>>,
|
||||
}
|
||||
|
||||
/// What a compute pipeline of a pack's program is made of.
|
||||
struct ComputeJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
module: wgpu::ShaderModule,
|
||||
}
|
||||
|
||||
/// A pipeline to make, and the slot it goes in.
|
||||
enum Job {
|
||||
Render(Slot<wgpu::RenderPipeline>, RenderJob),
|
||||
Compute(Slot<wgpu::ComputePipeline>, ComputeJob),
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Makes the pipeline (the driver compiles its shaders here) into its slot.
|
||||
fn create(&self, device: &wgpu::Device) {
|
||||
match self {
|
||||
Job::Render(slot, j) => {
|
||||
let buffers: Vec<Option<wgpu::VertexBufferLayout>> = j
|
||||
.buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|(stride, step, attributes)| wgpu::VertexBufferLayout {
|
||||
array_stride: *stride,
|
||||
step_mode: *step,
|
||||
attributes,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &j.vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: j.primitive,
|
||||
depth_stencil: j.depth_stencil.clone(),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &j.fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &j.targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
Job::Compute(slot, j) => {
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
module: &j.module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
Job::Render(_, j) => &j.label,
|
||||
Job::Compute(_, j) => &j.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the pipelines of `jobs` on as many threads as the machine has cores (up to 8): the
|
||||
/// driver compiles a big pack's programs for tens of seconds one at a time, on one core. The
|
||||
/// first one wgpu or the driver refuses, with why; `None` when all are made.
|
||||
///
|
||||
/// A refusal doesn't stop the others: every job is made, so that the serial rebuild `new` then
|
||||
/// runs finds the driver's shader cache warm instead of compiling the rest cold, one at a time.
|
||||
/// `cancel` set stops handing out jobs: the caller then drops the pack (`PackPost::new`).
|
||||
fn compile(
|
||||
device: &wgpu::Device,
|
||||
jobs: &[Job],
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Option<(String, String)> {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.clamp(1, 8)
|
||||
.min(jobs.len().max(1));
|
||||
let next = AtomicUsize::new(0);
|
||||
let refused = std::sync::Mutex::new(None);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..threads {
|
||||
scope.spawn(|| {
|
||||
while !cancel.load(Ordering::Relaxed)
|
||||
&& let Some(job) = jobs.get(next.fetch_add(1, Ordering::Relaxed))
|
||||
{
|
||||
if let Err(e) = checked(device, || job.create(device)) {
|
||||
let mut refused = refused.lock().unwrap_or_else(|e| e.into_inner());
|
||||
refused.get_or_insert((job.label().to_owned(), e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
refused.into_inner().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Vertex buffer layouts owned, for a `RenderJob`.
|
||||
fn owned_buffers(
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
) -> Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>> {
|
||||
buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|b| (b.array_stride, b.step_mode, b.attributes.to_vec()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1873,60 +2068,47 @@ fn build_pass(
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
packed::bitcast_outputs(&mut fragment, &int_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
let job = RenderJob {
|
||||
label: p.name.clone(),
|
||||
layout: pipeline_layout,
|
||||
vs,
|
||||
fs,
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or_else(Vec::new, |g| owned_buffers(g.buffers)),
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}),
|
||||
targets: targets.to_vec(),
|
||||
};
|
||||
let pipeline = descriptor(&targets);
|
||||
// The program's own blend replaced by vanilla's for the part; a buffer's own
|
||||
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
|
||||
// buffer only, with a probe pack of ours).
|
||||
// buffer only, with a probe pack of ours). Two more pipelines of the program, made
|
||||
// with the others (`compile`).
|
||||
let sky_parts = p.vanilla_sky_blends.then(|| {
|
||||
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
|
||||
let targets: Vec<_> = (targets.iter().enumerate())
|
||||
let targets: Vec<_> = (job.targets.iter().enumerate())
|
||||
.map(|(l, t)| {
|
||||
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
@@ -1935,10 +2117,16 @@ fn build_pass(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
descriptor(&targets)
|
||||
pack.render_pipeline(
|
||||
device,
|
||||
RenderJob {
|
||||
targets,
|
||||
..job.clone()
|
||||
},
|
||||
)
|
||||
})
|
||||
});
|
||||
(layout, pipeline, sky_parts)
|
||||
(layout, pack.render_pipeline(device, job), sky_parts)
|
||||
});
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -1970,6 +2158,7 @@ fn build_pass(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2077,15 +2266,12 @@ fn build_compute(
|
||||
label: Some(&name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&name),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(layout, pipeline)
|
||||
let job = ComputeJob {
|
||||
label: name.clone(),
|
||||
layout: pipeline_layout,
|
||||
module,
|
||||
};
|
||||
(layout, pack.compute_pipeline(device, job))
|
||||
});
|
||||
let (layout, pipeline) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -2105,6 +2291,7 @@ fn build_compute(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2153,17 +2340,73 @@ pub struct Refused {
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Refused {
|
||||
/// The reason of a build stopped by its `cancel` flag (`PackPost::new`).
|
||||
const CANCELLED: &str = "cancelled";
|
||||
fn cancelled(notes: Vec<String>) -> Self {
|
||||
Self {
|
||||
reason: Self::CANCELLED.into(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
/// Whether the build was cancelled (another pack or dimension asked for): not the pack's
|
||||
/// fault, so nothing to report to the player.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.reason == Self::CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
///
|
||||
/// The pack is laid out first and its pipelines are made after, all at once (`compile`).
|
||||
/// When wgpu or the driver refuses one, the pack is built again a program at a time, so that
|
||||
/// the program is skipped with its note as it always was.
|
||||
///
|
||||
/// `cancel` set (the pack isn't wanted any more) stops the build between programs: a refusal
|
||||
/// whose `is_cancelled` is true.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<Self, Refused> {
|
||||
use std::sync::atomic::Ordering;
|
||||
let spare = (post.clone(), world.clone());
|
||||
let (built, jobs) = Self::build(device, queue, adapter, post, screen, world, true, cancel)?;
|
||||
let refused = compile(device, &jobs, cancel);
|
||||
// Some pipelines may be left unmade: the pack can't draw.
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(built.notes));
|
||||
}
|
||||
let Some((program, refused)) = refused else {
|
||||
return Ok(built);
|
||||
};
|
||||
log::warn!(
|
||||
"shader pack: {program} was refused ({refused}): building it again a program at a time"
|
||||
);
|
||||
drop((built, jobs));
|
||||
let (post, world) = spare;
|
||||
Self::build(device, queue, adapter, post, screen, world, false, cancel)
|
||||
.map(|(built, _)| built)
|
||||
}
|
||||
|
||||
/// `new`'s work: with `defer`, the pipelines are left to make (the jobs returned).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Result<Self, Refused> {
|
||||
defer: bool,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<(Self, Vec<Job>), Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -2315,8 +2558,13 @@ impl PackPost {
|
||||
shadow_linear: (0..2)
|
||||
.filter(|&n| !shadow_config.nearest[n])
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let in_shadow = p.group.shadow();
|
||||
let mut compute = std::mem::take(&mut p.compute);
|
||||
if !gpu.compute {
|
||||
@@ -2376,7 +2624,16 @@ impl PackPost {
|
||||
} else {
|
||||
written.next()
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
|
||||
// Sizes of one kind that differ never meet. A size relative to the render size and
|
||||
// one in pixels can: the pass then draws while they resolve to one size
|
||||
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
|
||||
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
|
||||
let apart = |s: horizon_shaders::BufferSize| {
|
||||
first.is_some_and(|f| {
|
||||
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
|
||||
})
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(apart) {
|
||||
notes.push(format!(
|
||||
"{}: writes buffers of different sizes (size.buffer); skipped",
|
||||
pass.name
|
||||
@@ -2656,6 +2913,9 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(notes));
|
||||
}
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
@@ -2989,7 +3249,10 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Ok(Self {
|
||||
let jobs = (pack.jobs.as_ref())
|
||||
.map(std::cell::RefCell::take)
|
||||
.unwrap_or_default();
|
||||
let built = Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -3112,23 +3375,7 @@ impl PackPost {
|
||||
.create_view(&Default::default()),
|
||||
textures,
|
||||
shadow: (
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
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::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default()),
|
||||
super::pack_shadow::stand_in(device),
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
@@ -3174,7 +3421,76 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
})
|
||||
};
|
||||
Ok((built, jobs))
|
||||
}
|
||||
|
||||
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
|
||||
pub fn take_center_depth(&mut self, device: &wgpu::Device) -> Result<(), String> {
|
||||
self.center_depth
|
||||
.as_mut()
|
||||
.ok_or("the shader pack copied no centre depth")?
|
||||
.take_completed(device)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
|
||||
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
|
||||
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
|
||||
/// and the centre depth too. Compiled programs and the pack's textures stay.
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device) {
|
||||
self.uniforms.reset_history();
|
||||
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
|
||||
self.custom.restart_at(0);
|
||||
self.custom.forget_world();
|
||||
self.center_depth = None;
|
||||
self.targets = None;
|
||||
self.main = 0;
|
||||
self.mips_valid = 0;
|
||||
self.rgb_unset = true;
|
||||
self.final_target = None;
|
||||
self.blit_group = None;
|
||||
self.page_groups.clear();
|
||||
for image in &mut self.images {
|
||||
image.reset_history();
|
||||
}
|
||||
self.buffers.reset_history();
|
||||
pack_images::reset_stand_ins(device, &mut self.stand_ins);
|
||||
if let Some(shadow) = &mut self.shadow_targets {
|
||||
shadow.reset_history(device, &self.uniforms.shadow);
|
||||
}
|
||||
self.shadow.0 = super::pack_shadow::stand_in(device);
|
||||
self.shadow_cleared = false;
|
||||
self.shadow_cache = Default::default();
|
||||
self.setup_pending = true;
|
||||
self.portal_texture = None;
|
||||
self.portal_rect = [0.0; 4];
|
||||
let sky = self
|
||||
.sky
|
||||
.iter_mut()
|
||||
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
|
||||
let entities = self
|
||||
.entities
|
||||
.iter_mut()
|
||||
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
|
||||
for pass in self
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
.chain(&mut self.clouds)
|
||||
.chain(&mut self.lines)
|
||||
.chain(self.weather.iter_mut().flatten())
|
||||
.chain(self.hand.iter_mut().map(|h| &mut h.0))
|
||||
.chain(&mut self.shadow_terrain)
|
||||
.chain(&mut self.shadow_water)
|
||||
.chain(&mut self.shadow_comp)
|
||||
{
|
||||
pass.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
@@ -3434,6 +3750,7 @@ impl PackPost {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pipeline = pipeline.get().expect("compiled with its pack");
|
||||
let groups = groups.unwrap_or_else(|| {
|
||||
let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
|
||||
[n(0), n(1), 1]
|
||||
@@ -3648,8 +3965,8 @@ impl PackPost {
|
||||
pass.sky_parts
|
||||
.as_ref()
|
||||
.and_then(|[plain, stars]| match part.stage {
|
||||
"SKY" | "VOID" => Some(plain),
|
||||
"STARS" => Some(stars),
|
||||
"SKY" | "VOID" => plain.get(),
|
||||
"STARS" => stars.get(),
|
||||
_ => None,
|
||||
});
|
||||
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
|
||||
@@ -3928,7 +4245,7 @@ impl PackPost {
|
||||
frame: &super::Frame,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
if super::sky_visible(frame) {
|
||||
if super::sky_visible(frame) || super::end_fog_sky(frame) {
|
||||
self.draw_sky(device, queue, encoder, size, frame);
|
||||
}
|
||||
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
|
||||
@@ -4288,6 +4605,18 @@ impl PackPost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the buffers `pass` writes have different sizes at the current render size: then
|
||||
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
|
||||
/// a pass writing buffers sized relative to the render size and in pixels can.
|
||||
fn sizes_differ(&self, pass: &Pass) -> bool {
|
||||
let Some(targets) = &self.targets else {
|
||||
return false;
|
||||
};
|
||||
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
|
||||
let first = sizes.next();
|
||||
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself, or has nothing draw it.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
|
||||
@@ -4529,6 +4858,14 @@ impl PackPost {
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
|
||||
log::info!(
|
||||
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
|
||||
pass.name,
|
||||
size[0],
|
||||
size[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
|
||||
@@ -5075,6 +5412,10 @@ impl PackPost {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
// As if the pack didn't have it: no compute stages, flips or stage changes.
|
||||
if self.sizes_differ(pass) {
|
||||
continue;
|
||||
}
|
||||
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
|
||||
water_drawn = true;
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
@@ -5310,6 +5651,57 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A pack's pipelines are made together on several threads: each lands in its pass's slot,
|
||||
/// and one wgpu refuses comes back by name (the pack is then built a program at a time).
|
||||
#[test]
|
||||
fn pipelines_compile_together_and_a_refused_one_is_named() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = device.create_pipeline_layout(&Default::default());
|
||||
let module = |source: &str| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let job = |label: &str, module: wgpu::ShaderModule| {
|
||||
let slot = Slot::default();
|
||||
let job = ComputeJob {
|
||||
label: label.into(),
|
||||
layout: layout.clone(),
|
||||
module,
|
||||
};
|
||||
(slot.clone(), Job::Compute(slot, job))
|
||||
};
|
||||
let plain = "@compute @workgroup_size(1) fn main() {}";
|
||||
let go = std::sync::atomic::AtomicBool::new(false);
|
||||
let (slots, jobs): (Vec<_>, Vec<_>) = (0..12)
|
||||
.map(|i| job(&format!("composite{i}"), module(plain)))
|
||||
.unzip();
|
||||
// Cancelled before it starts: nothing is made (`new` then drops the pack).
|
||||
assert_eq!(compile(&device, &jobs, &true.into()), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_none()));
|
||||
assert_eq!(compile(&device, &jobs, &go), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
// A binding the layout doesn't have: wgpu refuses the pipeline.
|
||||
let bound = "@group(0) @binding(0) var<storage, read_write> b: array<u32>;\n\
|
||||
@compute @workgroup_size(1) fn main() { b[0] = 1u; }";
|
||||
let (_, bad) = job("deferred3", module(bound));
|
||||
let (slots, mut jobs): (Vec<_>, Vec<_>) =
|
||||
(0..4).map(|i| job(&format!("c{i}"), module(plain))).unzip();
|
||||
jobs.insert(2, bad);
|
||||
let (program, _why) = compile(&device, &jobs, &go).expect("the pipeline is refused");
|
||||
assert_eq!(program, "deferred3");
|
||||
// The refusal stops none of the others: they are all made.
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
@@ -5399,6 +5791,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let reads = |group: Group| {
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5493,6 +5886,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5576,6 +5970,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
@@ -5718,6 +6113,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let draws = DrawUniforms::new(&device);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
@@ -99,6 +99,7 @@ fn the_atlas_blends_its_two_nearest_levels() {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
for p in gpu.render(&mut post, [4, 4]) {
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
@@ -593,6 +594,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -244,6 +244,7 @@ impl Fixture {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -431,6 +432,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! In the End, while the dragon fight's world fog leaves out vanilla's sky, a pack's
|
||||
//! `gbuffers_skybasic` draws a fan in the fog colour, as our probe packs read it in Iris 1.11.7.
|
||||
//! Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// `gbuffers_skybasic` writes its vertex colour into colortex0 and `gl_Vertex` into colortex1
|
||||
/// (32-bit floats). final shows, on the picture's top half, `gl_Vertex.y` of the view's lower
|
||||
/// half as a 24-bit integer ((y + 64) * 65536), and on its bottom half colortex0 of the view's
|
||||
/// upper half.
|
||||
fn files() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("shaders.properties".into(), "blend.gbuffers_skybasic=off\n".into()),
|
||||
(
|
||||
"gbuffers_skybasic.vsh".into(),
|
||||
"#version 120\nvarying vec4 color;\nvarying vec3 at;\nvoid main() {\n\
|
||||
\x20 gl_Position = ftransform();\n color = gl_Color;\n at = gl_Vertex.xyz;\n}\n"
|
||||
.into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_skybasic.fsh".into(),
|
||||
"#version 120\nvarying vec4 color;\nvarying vec3 at;\n/* DRAWBUFFERS:01 */\n\
|
||||
void main() {\n gl_FragData[0] = color;\n gl_FragData[1] = vec4(at, 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
|
||||
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 float y = texelFetch(colortex1, ivec2(gl_FragCoord.xy) - ivec2(0, 8), 0).y;\n\
|
||||
\x20 uint n = min(uint((y + 64.0) * 65536.0 + 0.5), 16777215u);\n\
|
||||
\x20 vec4 at = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? at : texelFetch(colortex0, ivec2(gl_FragCoord.xy) + ivec2(0, 8), 0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The pack's picture of the fixture's frame (16 pixels square, 90° field of view, looking level
|
||||
/// toward +Z) in the End, the world having left the far plane everywhere.
|
||||
fn picture(gpu: &Gpu, boss_world_fog: bool) -> Vec<[u8; 4]> {
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files(),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.end_sky = true;
|
||||
frame.boss_world_fog = boss_world_fog;
|
||||
frame.detail_distance = 192.0;
|
||||
frame.fog = Some(crate::fog::FogData {
|
||||
color: [0.2, 0.4, 0.6, 1.0],
|
||||
environmental_start: 0.0,
|
||||
environmental_end: 96.0,
|
||||
render_distance_start: 172.8,
|
||||
render_distance_end: 192.0,
|
||||
sky_end: 96.0,
|
||||
cloud_end: 96.0,
|
||||
});
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// Where the view ray through pixel (`column`, `row`) meets the fan: from (0, -16, 0) to eight
|
||||
/// points 16 above the eye at radius 192, every 45° from +Z. None when it passes above the rim.
|
||||
fn fan_height(column: usize, row: usize) -> Option<f64> {
|
||||
let side = |i: usize| (i as f64 + 0.5) / 8.0 - 1.0;
|
||||
// The fan is mirrored across x = 0, so either sign of the view's x does.
|
||||
let ray = glam::DVec3::new(side(column), -side(row), 1.0);
|
||||
let azimuth = ray.x.atan2(ray.z).to_degrees();
|
||||
let edge = (azimuth / 45.0).floor();
|
||||
let rim = |k: f64| {
|
||||
let a = (k * 45.0).to_radians();
|
||||
glam::DVec3::new(192.0 * a.sin(), 16.0, 192.0 * a.cos())
|
||||
};
|
||||
let apex = glam::DVec3::new(0.0, -16.0, 0.0);
|
||||
let normal = (rim(edge) - apex).cross(rim(edge + 1.0) - apex);
|
||||
let along = apex.dot(normal) / ray.dot(normal);
|
||||
let hit = ray * along;
|
||||
(along > 0.0 && hit.y <= 16.0).then_some(hit.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_under_the_dragons_fog_has_the_packs_fan() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let pixels = picture(&gpu, true);
|
||||
let width = SIZE[0] as usize;
|
||||
// Below the horizon: every pixel sees the fan, at the height the planes give (to 0.02:
|
||||
// the GPU interpolates `gl_Vertex` across triangles 192 blocks wide; it came within 0.008).
|
||||
for row in 8..16 {
|
||||
for column in 0..width {
|
||||
let p = pixels[(row - 8) * width + column];
|
||||
let got = f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
/ 65536.0
|
||||
- 64.0;
|
||||
let expected = fan_height(column, row).expect("below the horizon");
|
||||
assert!(
|
||||
(got - expected).abs() < 0.02,
|
||||
"row {row}, column {column}: gl_Vertex.y {got}, expected {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Above it: only the row just over the horizon reaches the fan (the rim stands 4.8° up),
|
||||
// drawn in the fog colour; above that, the world's grey stays.
|
||||
for row in 0..8 {
|
||||
for column in 0..width {
|
||||
let p = pixels[(row + 8) * width + column];
|
||||
let expected = match fan_height(column, row) {
|
||||
Some(_) => [51, 102, 153, 255],
|
||||
None => [16, 16, 16, 255],
|
||||
};
|
||||
assert_eq!(p, expected, "row {row}, column {column}");
|
||||
}
|
||||
assert_eq!(fan_height(0, row).is_some(), row == 7, "row {row}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The End without the fog: vanilla's End sky, which no `gbuffers_skybasic` draws.
|
||||
#[test]
|
||||
fn the_end_without_the_fog_has_no_fan() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let pixels = picture(&gpu, false);
|
||||
let width = SIZE[0] as usize;
|
||||
for (i, p) in pixels[8 * width..].iter().enumerate() {
|
||||
assert_eq!(*p, [16, 16, 16, 255], "pixel {i}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU with compute and fragment storage"]
|
||||
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
|
||||
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
|
||||
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
|
||||
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
|
||||
add(
|
||||
"setup.csh",
|
||||
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
|
||||
);
|
||||
add(
|
||||
"begin.csh",
|
||||
&format!(
|
||||
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
|
||||
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
|
||||
),
|
||||
);
|
||||
for name in ["composite", "shadowcomp"] {
|
||||
add(&format!("{name}.vsh"), vertex);
|
||||
let target = if name == "composite" { 2 } else { 0 };
|
||||
let (declarations, value) = if name == "composite" {
|
||||
("", "vec4(0.125)")
|
||||
} else {
|
||||
(
|
||||
"uniform sampler2D shadowcolor0;varying vec2 uv;",
|
||||
"texture2D(shadowcolor0,uv)+vec4(0.125)",
|
||||
)
|
||||
};
|
||||
add(
|
||||
&format!("{name}.fsh"),
|
||||
&format!(
|
||||
"#version 120\n/* RENDERTARGETS:{target} */\n\
|
||||
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
|
||||
),
|
||||
);
|
||||
}
|
||||
add("final.vsh", vertex);
|
||||
add(
|
||||
"final.fsh",
|
||||
&format!(
|
||||
"#version 430 compatibility\n{state}\
|
||||
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
|
||||
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
|
||||
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
|
||||
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
|
||||
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
|
||||
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
|
||||
),
|
||||
);
|
||||
add(
|
||||
"shaders.properties",
|
||||
"shadow.enabled=true\nbufferObject.0=8\n\
|
||||
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
|
||||
blend.composite=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let load = || horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
|
||||
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
|
||||
let check = |pixels: &[[u8; 4]], frame: u8| {
|
||||
let counter = 32 * frame;
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
let expected = if i % 16 < 8 {
|
||||
[counter, counter, counter, 255]
|
||||
} else {
|
||||
[counter, 32, 96, 255]
|
||||
};
|
||||
assert!(
|
||||
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
|
||||
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
for frame in 1..=3 {
|
||||
check(&gpu.render(&mut post, [16, 16]), frame);
|
||||
}
|
||||
// At the same size too: the retained colortex2 and shadowcolor0, the image and
|
||||
// the storage buffer start over, and setup runs once again. The control is a pack never
|
||||
// reset.
|
||||
for _ in 0..2 {
|
||||
post.reset_history(&gpu.device);
|
||||
for frame in 1..=2 {
|
||||
let pixels = gpu.render(&mut post, [16, 16]);
|
||||
check(&pixels, frame);
|
||||
if frame == 1 {
|
||||
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
|
||||
}
|
||||
}
|
||||
fresh.reset_history(&gpu.device);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
|
||||
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
|
||||
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
|
||||
//! original and cut down.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
|
||||
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
|
||||
/// blue.
|
||||
fn pack(colortex3: &str) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add(
|
||||
"shaders.properties",
|
||||
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
|
||||
);
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
|
||||
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
|
||||
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
/// Every pixel's colour, with how many pixels differ from it by more than one step.
|
||||
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
|
||||
let first = pixels[0];
|
||||
let off = pixels
|
||||
.iter()
|
||||
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
|
||||
.count();
|
||||
(first, off)
|
||||
}
|
||||
|
||||
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
|
||||
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
|
||||
/// the whole pack in that case; Horizon skips only the program).
|
||||
#[test]
|
||||
fn a_program_writing_buffers_that_come_to_one_size_draws() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack("4 4"));
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
|
||||
assert_eq!(off, 0, "{colour:?}");
|
||||
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
|
||||
assert!(got.abs_diff(want) <= 1, "{colour:?}");
|
||||
}
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
|
||||
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
|
||||
}
|
||||
|
||||
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
|
||||
#[test]
|
||||
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let post = pack("0.5 0.5");
|
||||
let post = gpu
|
||||
.try_build(post)
|
||||
.unwrap_or_else(|_| panic!("the pack runs"));
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ fn pack(
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
|
||||
@@ -304,6 +304,7 @@ fn pack_with(
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
@@ -400,6 +401,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -76,7 +76,50 @@ fn target(
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
/// The depth stand-in is initialized to no shadow on the next pack render.
|
||||
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
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::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
Some([a, b]) => {
|
||||
colors |= 1 << n;
|
||||
if a.texture() != b.texture() {
|
||||
doubled |= 1 << n;
|
||||
}
|
||||
a.texture().format()
|
||||
}
|
||||
None => wgpu::TextureFormat::Rgba8Unorm,
|
||||
});
|
||||
*self = Self::new(
|
||||
device,
|
||||
shadow,
|
||||
formats,
|
||||
colors,
|
||||
doubled,
|
||||
self.depth1.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
|
||||
/// resolution is kept within the device's limit.
|
||||
|
||||
@@ -22,6 +22,8 @@ const SUNSET_APEX: u32 = 1;
|
||||
const SUNSET_RIM: u32 = 2;
|
||||
const STAR: u32 = 3;
|
||||
const VOID: u32 = 4;
|
||||
/// The End's fan (`fan`): `pos.xz` of a rim vertex is its direction, scaled by the radius.
|
||||
const FAN: u32 = 5;
|
||||
|
||||
/// The frame's sky (std140).
|
||||
#[repr(C)]
|
||||
@@ -33,7 +35,7 @@ struct Uniform {
|
||||
color: [f32; 4],
|
||||
/// Sunset colour and alpha.
|
||||
sunrise: [f32; 4],
|
||||
/// cos and sin of the sunset fan's turn, star brightness.
|
||||
/// cos and sin of the sunset fan's turn, star brightness, the End fan's radius.
|
||||
sun: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -77,6 +79,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}} else if (hz_v_kind == {STAR}u) {{\n \
|
||||
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
|
||||
hz_Color = vec4(hz_sky_sun.z);\n \
|
||||
}} else if (hz_v_kind == {FAN}u) {{\n \
|
||||
hz_p = vec3(hz_v_pos.x * hz_sky_sun.w, hz_v_pos.y, hz_v_pos.z * hz_sky_sun.w);\n \
|
||||
}} else if (hz_v_kind == {VOID}u) {{\n \
|
||||
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
|
||||
}}\n \
|
||||
@@ -188,14 +192,37 @@ fn horizon(out: &mut Vec<Vertex>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fan a pack's `gbuffers_skybasic` draws in the End while the dragon fight's fog leaves out
|
||||
/// vanilla's sky (`super::end_fog_sky`): from (0, -16, 0) under the eye to eight rim vertices 16
|
||||
/// above it, every 45° from +Z, at the render distance (the `far` uniform; scaled in the vertex
|
||||
/// stage). Fitted to the `gl_Vertex` our probe packs' fragments read in Iris 1.11.7 at render
|
||||
/// distances 12 and 8: 289 of 291 samples lie within 0.05 block of these eight planes (one of
|
||||
/// the other two, checked, sat on a terrain edge), and nothing drew above the rim.
|
||||
fn fan(out: &mut Vec<Vertex>) {
|
||||
let rim = |i: i32| {
|
||||
let a = (i as f32 * 45.0).to_radians();
|
||||
Vertex {
|
||||
pos: [a.sin(), 16.0, a.cos()],
|
||||
kind: FAN,
|
||||
}
|
||||
};
|
||||
let apex = Vertex {
|
||||
pos: [0.0, -16.0, 0.0],
|
||||
kind: FAN,
|
||||
};
|
||||
for i in 0..8 {
|
||||
out.extend([apex, rim(i), rim(i + 1)]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PackSky {
|
||||
vertices: wgpu::Buffer,
|
||||
celestial: wgpu::Buffer,
|
||||
uniform: wgpu::Buffer,
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
|
||||
ranges: [Range<u32>; 4],
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
|
||||
ranges: [Range<u32>; 5],
|
||||
}
|
||||
|
||||
impl PackSky {
|
||||
@@ -227,6 +254,8 @@ impl PackSky {
|
||||
let stars = sunset.end..v.len() as u32;
|
||||
disc(-4.0, VOID, &mut v);
|
||||
let void = stars.end..v.len() as u32;
|
||||
fan(&mut v);
|
||||
let end_fan = void.end..v.len() as u32;
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack sky"),
|
||||
contents: bytemuck::cast_slice(&v),
|
||||
@@ -271,7 +300,7 @@ impl PackSky {
|
||||
uniform,
|
||||
layout,
|
||||
group,
|
||||
ranges: [sky, sunset, stars, void],
|
||||
ranges: [sky, sunset, stars, void, end_fan],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +323,8 @@ impl PackSky {
|
||||
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
|
||||
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
|
||||
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
|
||||
/// and stars turn with it (7.1).
|
||||
/// and stars turn with it (7.1). In the End under the dragon fight's fog, only the End's fan,
|
||||
/// in the fog colour (`fogColor`), as stage SKY.
|
||||
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
|
||||
let turn = super::pack_uniforms::tilt_matrix(tilt);
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
@@ -302,12 +332,22 @@ impl PackSky {
|
||||
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
|
||||
let theta = (flip + 90.0).to_radians();
|
||||
let (rotation, brightness) = frame.stars.unwrap_or_default();
|
||||
let [r, g, b] = frame.sky.sky_color;
|
||||
let end_fan = super::end_fog_sky(frame);
|
||||
let [r, g, b] = match &frame.fog {
|
||||
Some(f) if end_fan => [f.color[0], f.color[1], f.color[2]],
|
||||
None if end_fan => frame.sky.fog_color,
|
||||
_ => frame.sky.sky_color,
|
||||
};
|
||||
let uniform = Uniform {
|
||||
stars: (turn * rotation).to_cols_array_2d(),
|
||||
color: [r, g, b, 1.0],
|
||||
sunrise: frame.sky.sunrise_color,
|
||||
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
|
||||
sun: [
|
||||
theta.cos(),
|
||||
theta.sin(),
|
||||
brightness.clamp(0.0, 1.0),
|
||||
frame.detail_distance,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
|
||||
@@ -327,12 +367,15 @@ impl PackSky {
|
||||
})
|
||||
.collect();
|
||||
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
|
||||
let [sky, sunset, stars, void] = self.ranges.clone();
|
||||
let [sky, sunset, stars, void, fan] = self.ranges.clone();
|
||||
let part = |stage, textured, range| Part {
|
||||
stage,
|
||||
textured,
|
||||
range,
|
||||
};
|
||||
if end_fan {
|
||||
return vec![part("SKY", false, fan)];
|
||||
}
|
||||
let mut parts = vec![part("SKY", false, sky)];
|
||||
if frame.sky.sunrise_color[3] > 0.001 {
|
||||
parts.push(part("SUNSET", false, sunset));
|
||||
|
||||
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"cloudTime",
|
||||
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
|
||||
// many, and each one's camera-relative position and light level (zeros past the count).
|
||||
"horizonDynamicLightCount",
|
||||
"horizonDynamicLight0",
|
||||
"horizonDynamicLight1",
|
||||
"horizonDynamicLight2",
|
||||
"horizonDynamicLight3",
|
||||
"horizonDynamicLight4",
|
||||
"horizonDynamicLight5",
|
||||
"horizonDynamicLight6",
|
||||
"horizonDynamicLight7",
|
||||
"horizonDynamicLight8",
|
||||
"horizonDynamicLight9",
|
||||
"horizonDynamicLight10",
|
||||
"horizonDynamicLight11",
|
||||
"horizonDynamicLight12",
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -709,6 +728,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl FrameUniforms {
|
||||
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
|
||||
/// centre depth), keeping the pack's atlas size and shadow settings.
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
*self = Self {
|
||||
atlas: self.atlas,
|
||||
shadow: std::mem::take(&mut self.shadow),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -919,6 +948,12 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
.copied()
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"heldItemId" => Value::i(&[inputs.held_items[0]]),
|
||||
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
|
||||
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
|
||||
@@ -1775,6 +1810,29 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dynamic_lights_reach_the_pack_nearest_first() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
|
||||
f.dynamic_lights = &lights;
|
||||
let mut u = FrameUniforms::default();
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
let count = index("horizonDynamicLightCount").unwrap();
|
||||
assert_eq!(int(&u, count), 2);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
|
||||
// Past the count, and the last of the sixteen: zeros.
|
||||
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
|
||||
assert_eq!(index("horizonDynamicLight16"), None);
|
||||
// Off (no lights): none.
|
||||
f.dynamic_lights = &[];
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(int(&u, count), 0);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2367,6 +2425,61 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
|
||||
}
|
||||
|
||||
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
|
||||
/// at their targets, the clock from 0; the atlas size it was given stays.
|
||||
#[test]
|
||||
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
|
||||
let hud = HudBatch::new([16, 16]);
|
||||
let mut f = frame(&hud);
|
||||
let mut uniforms = FrameUniforms {
|
||||
atlas: [256, 128],
|
||||
..Default::default()
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
rain: 1.0,
|
||||
eye_light: [15, 15],
|
||||
frame_timing: Some(FrameTiming {
|
||||
counter: 800,
|
||||
elapsed: 99.0,
|
||||
delta: 1.0,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
uniforms.center_depth_raw = Some(0.25);
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
|
||||
f.yaw = 77.0;
|
||||
inputs.rain = 0.25;
|
||||
inputs.eye_light = [3, 8];
|
||||
inputs.frame_timing = None;
|
||||
uniforms.reset_history();
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
let float = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
};
|
||||
let int = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::I(v) => v,
|
||||
_ => panic!("integer uniform expected"),
|
||||
};
|
||||
assert_eq!(int("frameCounter")[0], 0);
|
||||
assert_eq!(float("frameTime")[0], 0.0);
|
||||
assert_eq!(int("atlasSize")[..2], [256, 128]);
|
||||
assert_eq!(float("wetness")[0], 0.25);
|
||||
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
|
||||
assert_eq!(float("centerDepthSmooth")[0], 1.0);
|
||||
for (previous, current) in [
|
||||
("previousCameraPosition", "cameraPosition"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
] {
|
||||
assert_eq!(
|
||||
float(previous).map(f32::to_bits),
|
||||
float(current).map(f32::to_bits)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
@@ -2411,6 +2524,7 @@ pub(super) mod initial_history_tests {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
@@ -2468,6 +2582,12 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
(k < crate::dynamic_light::MAX).then_some(k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_uniforms/ftransform_test.rs"]
|
||||
mod ftransform_test;
|
||||
@@ -323,4 +323,27 @@ mod tests {
|
||||
let unorm = wgpu::TextureFormat::Rgba16Unorm;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
|
||||
}
|
||||
|
||||
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
|
||||
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
|
||||
/// the way.
|
||||
#[test]
|
||||
fn a_float_frame_reaches_an_8_bit_swapchain() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let color = wgpu::Color {
|
||||
r: 1.5,
|
||||
g: 0.2,
|
||||
b: -0.5,
|
||||
a: 1.0,
|
||||
};
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Opaque/cutout terrain-sheet native models, with ordinary culling and depth writes.
|
||||
pub(super) fn pipelines(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
sprite_constants: &[(&str, f64)],
|
||||
) -> [wgpu::RenderPipeline; 3] {
|
||||
std::array::from_fn(|i| {
|
||||
let mut constants = sprite_constants.to_vec();
|
||||
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
|
||||
// The ordinary Vertex ABI is unchanged. This material's private payload uses
|
||||
// normal's four bytes for a float z, and glint UV for float normal x/y.
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Uint32,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(
|
||||
[
|
||||
"Native model solid",
|
||||
"Native model cutout",
|
||||
"Native model cutout mipmapped",
|
||||
][i],
|
||||
),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_native_model"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_native_model"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
|
||||
textureDimensions(horizon_entity_atlas));
|
||||
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
|
||||
if color.a < 0.1 { discard; }
|
||||
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
|
||||
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
|
||||
in.snow != 0u);
|
||||
let out = weather(input);
|
||||
return vec4(horizon_line_out(out.rgb), out.a);
|
||||
}
|
||||
";
|
||||
|
||||
/// The shader of a native pack's weather stage `source`.
|
||||
fn native_source(source: &str) -> String {
|
||||
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
|
||||
super::native_post::line_helpers(api)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
use horizon_shader_api::stages::GROUP;
|
||||
let text = native_source(&stage.source);
|
||||
let text = native_source(&stage.source, frame.api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
let present = super::native_post::present_constants(&module, constants);
|
||||
@@ -318,19 +322,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_native_weather_stage_validates() {
|
||||
use horizon_shader_api::stages::StageKind;
|
||||
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
use horizon_shader_api::{Api, stages::StageKind};
|
||||
for api in [Api::V0_1, Api::V0_2] {
|
||||
let source = StageKind::Weather.prelude(api)
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
|
||||
let text = native_source(&source, api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
use crate::weather::{Parameters, Time};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -55,6 +55,13 @@ pub struct Post {
|
||||
sampler: wgpu::Sampler,
|
||||
copy: wgpu::RenderPipeline,
|
||||
fxaa: wgpu::RenderPipeline,
|
||||
/// Contrast-adaptive sharpening of TAA's upscaled picture: the jittered frames are
|
||||
/// spread over more output pixels, and the blend softens edges.
|
||||
sharpen: wgpu::RenderPipeline,
|
||||
/// The strength `sharpen` was built with (`set_sharpness`), and what rebuilding it takes.
|
||||
sharpness: f32,
|
||||
shader: wgpu::ShaderModule,
|
||||
pipeline_layout: wgpu::PipelineLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
/// World resolution relative to the window (0.5..=2.0).
|
||||
pub scale: f32,
|
||||
@@ -232,8 +239,12 @@ impl Post {
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
shader: shader.clone(),
|
||||
pipeline_layout: pipeline_layout.clone(),
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
sharpen: sharpen_pipeline(device, &shader, &pipeline_layout, format, 0.85),
|
||||
sharpness: 0.85,
|
||||
layout,
|
||||
sampler,
|
||||
format,
|
||||
@@ -257,14 +268,23 @@ impl Post {
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
self.sizes_with_upscale(window, upscale)
|
||||
}
|
||||
|
||||
fn sizes_with_upscale(&self, window: [u32; 2], upscale: f32) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// The next TAA frame's input size, even before its first jitter or resize.
|
||||
pub(super) fn taa_render_size(&self, window: [u32; 2]) -> [u32; 2] {
|
||||
self.sizes_with_upscale(window, self.upscale).0
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
@@ -465,6 +485,21 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Upscaling Sharpness setting, 0..=1: rebuilds the sharpen pipeline when it changed.
|
||||
pub fn set_sharpness(&mut self, device: &wgpu::Device, sharpness: f32) {
|
||||
let sharpness = sharpness.clamp(0.0, 1.0);
|
||||
if sharpness != self.sharpness {
|
||||
self.sharpen = sharpen_pipeline(
|
||||
device,
|
||||
&self.shader,
|
||||
&self.pipeline_layout,
|
||||
self.format,
|
||||
sharpness,
|
||||
);
|
||||
self.sharpness = sharpness;
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
@@ -526,7 +561,12 @@ impl Post {
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
self.finish_with(encoder, screen, &taa.show[i], timing);
|
||||
let pipeline = if self.upscale > 1.0 {
|
||||
&self.sharpen
|
||||
} else {
|
||||
&self.copy
|
||||
};
|
||||
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
@@ -536,6 +576,23 @@ impl Post {
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let pipeline = if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
};
|
||||
self.draw_onto(encoder, screen, group, pipeline, timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds onto `screen` through `pipeline`.
|
||||
fn draw_onto(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
screen: &wgpu::TextureView,
|
||||
group: &wgpu::BindGroup,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: screen,
|
||||
@@ -552,16 +609,51 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(if self.antialias {
|
||||
&self.fxaa
|
||||
} else {
|
||||
&self.copy
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `fs_sharpen` at `sharpness` (its override), drawing onto `format`.
|
||||
fn sharpen_pipeline(
|
||||
device: &wgpu::Device,
|
||||
shader: &wgpu::ShaderModule,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
sharpness: f32,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let constants = [("sharpness", f64::from(sharpness))];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon sharpen"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_sharpen"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
pub(super) fn reprojection(
|
||||
@@ -604,7 +696,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
for i in 1..=128 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
@@ -614,6 +706,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn culling_uses_the_next_taa_size_before_the_first_jitter_or_resize() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
assert!(!post.taa_on);
|
||||
assert!(post.size().is_none());
|
||||
for (window, scale, upscale, expected) in [
|
||||
([1280, 720], 1.0, 1.0, [1280, 720]),
|
||||
([1281, 721], 0.5, 2.0, [321, 181]),
|
||||
([3840, 2160], 0.25, 4.0, [240, 135]),
|
||||
([10000, 10000], 2.0, 4.0, [2048, 2048]),
|
||||
([0, 0], 0.5, 4.0, [1, 1]),
|
||||
] {
|
||||
post.scale = scale;
|
||||
post.upscale = upscale;
|
||||
post.taa_on = false;
|
||||
assert_eq!(post.taa_render_size(window), expected);
|
||||
post.taa_on = true;
|
||||
assert_eq!(post.sizes(window).0, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -725,6 +725,8 @@ pub struct Graphics {
|
||||
pub taa: bool,
|
||||
/// TAA upscaling; anything but `Native` comes with `taa` on.
|
||||
pub upscaling: Upscaling,
|
||||
/// How much the upscaled picture is sharpened, 0..=100 (percent of the most).
|
||||
pub upscaling_sharpness: u32,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
@@ -813,6 +815,7 @@ impl Default for Graphics {
|
||||
fxaa: false,
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
upscaling_sharpness: 85,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
@@ -935,6 +938,9 @@ impl Graphics {
|
||||
"fxaa" => g.fxaa = flag(g.fxaa),
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"upscalingSharpness" => {
|
||||
g.upscaling_sharpness = number(g.upscaling_sharpness).min(100)
|
||||
}
|
||||
"screenshotQuality" => {
|
||||
g.screenshot_quality = Some(number(0))
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
@@ -980,7 +986,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -1019,6 +1025,7 @@ impl Graphics {
|
||||
bool_text(self.fxaa),
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.upscaling_sharpness,
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
@@ -1092,8 +1099,9 @@ impl Graphics {
|
||||
// (horizon_threads: niced on Linux; on macOS and Windows the server
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
|
||||
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
|
||||
// silicon only the performance cores count (horizon_threads::default_workers).
|
||||
let auto = horizon_threads::jobs::default_threads();
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
|
||||
@@ -1201,6 +1209,7 @@ mod tests {
|
||||
fxaa: true,
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
upscaling_sharpness: 40,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
@@ -1476,7 +1485,10 @@ mod tests {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = horizon_threads::worker_count();
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
|
||||
if !cfg!(target_vendor = "apple") {
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
}
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -97,6 +97,7 @@ pub enum H {
|
||||
ResolutionScale,
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
UpscalingSharpness,
|
||||
ScreenshotQuality,
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
@@ -372,6 +373,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
High,
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::UpscalingSharpness,
|
||||
Low,
|
||||
"Sharpens the upscaled picture. Higher: crisper edges, more shimmer.",
|
||||
),
|
||||
h(
|
||||
H::ScreenshotQuality,
|
||||
None,
|
||||
@@ -639,12 +645,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
|
||||
/// for a short ring of far terrain past a small render distance (a phone or an
|
||||
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
|
||||
/// up to the whole world.
|
||||
const LOD_STEPS: [u32; 18] = [
|
||||
const LOD_STEPS: [u32; 24] = [
|
||||
0,
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
96,
|
||||
128,
|
||||
192,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
@@ -777,6 +791,7 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
@@ -796,6 +811,7 @@ impl H {
|
||||
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(41),
|
||||
H::HdrPeak => Some(37),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
@@ -885,6 +901,9 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::UpscalingSharpness => {
|
||||
labelled("Upscaling Sharpness", format!("{}%", g.upscaling_sharpness))
|
||||
}
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
@@ -957,6 +976,7 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
@@ -1655,6 +1675,7 @@ impl GraphicsUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, cols, y, &view);
|
||||
}
|
||||
@@ -2692,12 +2713,21 @@ mod tests {
|
||||
item.label(&s).plain(),
|
||||
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
|
||||
);
|
||||
item.set_slider(&mut s, 5.0 / 17.0);
|
||||
item.set_slider(&mut s, 11.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 1024);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
|
||||
// The fine stops: a ring of far terrain just past a small render
|
||||
// distance, which the doubling steps skipped (#9694).
|
||||
item.set_slider(&mut s, 1.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 16);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
|
||||
item.set_slider(&mut s, 2.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 24);
|
||||
// A distance between stops (from the file) sits on the next one.
|
||||
s.horizon.lod_distance = 3000;
|
||||
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
@@ -3204,6 +3234,7 @@ mod tests {
|
||||
H::UploadBudget,
|
||||
H::Workers,
|
||||
H::ResolutionScale,
|
||||
H::UpscalingSharpness,
|
||||
H::HdrPaperWhite,
|
||||
H::HdrPeak,
|
||||
H::HdrBlackLevel,
|
||||
|
||||
@@ -33,6 +33,11 @@ struct Params {
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
|
||||
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// much of the world it hides there (gfx/hud_coverage.rs).
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@group(0) @binding(5) var hud: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -61,9 +66,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
|
||||
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
|
||||
// would read differently (an alpha of 0.001 would count as the hand); its colour
|
||||
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
|
||||
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
|
||||
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
|
||||
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
|
||||
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -148,44 +154,29 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
|
||||
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
|
||||
struct Lit {
|
||||
light: vec3<f32>,
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && a >= 0.75 && a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
@@ -194,10 +185,13 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
|
||||
// brighter, and where in the band how much (hdr_mark).
|
||||
if shows && a < 0.75 {
|
||||
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
@@ -214,8 +208,27 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return Lit(min(light, vec3(params.peak)), lifted);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
fn calibrating(pos: vec4<f32>) -> bool {
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
|
||||
}
|
||||
|
||||
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
@@ -226,3 +239,67 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's marks lift only where nothing was drawn over them since: grey text over
|
||||
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
|
||||
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
|
||||
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
|
||||
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
|
||||
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
|
||||
let shows = params.world > 0.5 && params.peak > 1.0;
|
||||
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
|
||||
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
}
|
||||
@@ -531,6 +531,49 @@ fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
|
||||
}
|
||||
/// The texture of every `bitmap` font provider. A provider's `file` may name
|
||||
/// any texture, so a pack's glyph sheet need not live under `textures/font`.
|
||||
fn bitmap_font_textures(root: &Path) -> Vec<PathBuf> {
|
||||
fn jsons(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
jsons(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
let mut defs = Vec::new();
|
||||
for ns in fs::read_dir(&assets).into_iter().flatten().flatten() {
|
||||
jsons(&ns.path().join("font"), &mut defs);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for def in defs {
|
||||
let Ok(bytes) = fs::read(&def) else { continue };
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
|
||||
let Ok(json) = serde_json::from_slice::<serde_json::Value>(bytes) else {
|
||||
continue;
|
||||
};
|
||||
for p in json["providers"].as_array().into_iter().flatten() {
|
||||
if p["type"] != "bitmap" {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = p["file"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let (ns, path) = file.split_once(':').unwrap_or(("minecraft", file));
|
||||
let path = assets.join(ns).join("textures").join(path);
|
||||
if path.is_file() && seen.insert(path.clone()) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
@@ -1102,6 +1145,18 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// A bitmap provider's `file` may be outside the walked folders (HUD
|
||||
// packs keep their glyph sheets under their own namespace or path).
|
||||
// Pack those sheets too, so Font reads them from the atlas instead of
|
||||
// copying every glyph into the small run-time region.
|
||||
if folders.contains(&"font") {
|
||||
let mut seen: HashSet<PathBuf> = files.iter().cloned().collect();
|
||||
files.extend(
|
||||
bitmap_font_textures(root)
|
||||
.into_iter()
|
||||
.filter(|p| seen.insert(p.clone())),
|
||||
);
|
||||
}
|
||||
// Content mods' entity textures: their equipment assets name them as
|
||||
// `<ns>:<path>`, and native mods draw their block entities with them
|
||||
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
|
||||
@@ -1333,6 +1388,36 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
if full {
|
||||
// Official Recipes artwork from Just Enough Items, under MIT.
|
||||
for (name, png) in [
|
||||
(
|
||||
"button_enabled_v2",
|
||||
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_disabled_v2",
|
||||
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_highlight_v2",
|
||||
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_previous_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_next_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
|
||||
),
|
||||
] {
|
||||
let (width, height, rgba) = decode_bytes(png)?;
|
||||
images.push((format!("horizon/jei/{name}"), width, height, rgba));
|
||||
}
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
// and black quarters.
|
||||
@@ -1354,6 +1439,11 @@ impl HudAtlas {
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
if full {
|
||||
let (width, height, rgba) =
|
||||
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
|
||||
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
@@ -1430,8 +1520,9 @@ impl HudAtlas {
|
||||
let white = sprites["horizon/white"];
|
||||
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
|
||||
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
|
||||
let font = crate::font::Font::load(
|
||||
let font = crate::font::Font::load_over(
|
||||
root,
|
||||
defaults,
|
||||
&sprites,
|
||||
&pixels,
|
||||
(width, height),
|
||||
@@ -2344,17 +2435,19 @@ impl HudUniform {
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::gfx::hud_coverage::{self, Pipeline};
|
||||
|
||||
pub struct HudRenderer {
|
||||
pub sprite_animations: Vec<(Rect, crate::sprites::SpriteAnimation, (usize, usize, u32))>,
|
||||
/// The shared atlas texture, for run-time glyph uploads.
|
||||
pub atlas_texture: Option<wgpu::Texture>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
pipeline: Pipeline,
|
||||
/// `RenderPipelines.CROSSHAIR`.
|
||||
invert_pipeline: wgpu::RenderPipeline,
|
||||
additive_pipeline: wgpu::RenderPipeline,
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
invert_pipeline: Pipeline,
|
||||
additive_pipeline: Pipeline,
|
||||
depth_pipeline: Pipeline,
|
||||
vignette_pipeline: Pipeline,
|
||||
end_portal_pipeline: Pipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
@@ -2364,6 +2457,7 @@ pub struct HudRenderer {
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
format: wgpu::TextureFormat,
|
||||
coverage: bool,
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
@@ -2378,6 +2472,7 @@ impl HudRenderer {
|
||||
view: &wgpu::TextureView,
|
||||
glint_view: &wgpu::TextureView,
|
||||
glint_sampler: &wgpu::Sampler,
|
||||
coverage: bool,
|
||||
) -> Result<Self> {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HUD"),
|
||||
@@ -2490,7 +2585,7 @@ impl HudRenderer {
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2524,7 +2619,9 @@ impl HudRenderer {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
|
||||
};
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
|
||||
let depth_pipeline = create(
|
||||
@@ -2615,6 +2712,7 @@ impl HudRenderer {
|
||||
buffer,
|
||||
capacity,
|
||||
format,
|
||||
coverage,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
zoom: 1.0,
|
||||
@@ -2629,7 +2727,7 @@ impl HudRenderer {
|
||||
textures: Option<&cit_glint::GlintTextures>,
|
||||
) {
|
||||
self.cit_glint = textures.and_then(|t| {
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t, self.coverage)
|
||||
});
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
@@ -2639,7 +2737,7 @@ impl HudRenderer {
|
||||
shader: Option<&horizon_shaders::CoreProgram>,
|
||||
) {
|
||||
self.pack_text = shader.and_then(|shader| {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
@@ -2788,6 +2886,7 @@ impl HudRenderer {
|
||||
self.draw_range(
|
||||
encoder,
|
||||
target,
|
||||
None,
|
||||
depth,
|
||||
batch,
|
||||
size,
|
||||
@@ -2801,6 +2900,7 @@ impl HudRenderer {
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
coverage: Option<&wgpu::TextureView>,
|
||||
depth: &wgpu::TextureView,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
@@ -2810,7 +2910,7 @@ impl HudRenderer {
|
||||
if vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
}
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
@@ -2819,6 +2919,9 @@ impl HudRenderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
if let Some(view) = coverage {
|
||||
attachments.push(Some(hud_coverage::attachment(view)));
|
||||
}
|
||||
let runs = batch.runs_in(vertices);
|
||||
let mut at = 0;
|
||||
while at < runs.len() {
|
||||
@@ -2870,16 +2973,17 @@ impl HudRenderer {
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
if let Some(cit) = &self.cit_glint {
|
||||
cit.draw(&mut pass, *blend, range.clone());
|
||||
cit.draw(&mut pass, *blend, range.clone(), coverage.is_some());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let pipeline = pipeline.get(coverage.is_some());
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
|
||||
if text {
|
||||
pack_text.draw(&mut pass, part);
|
||||
pack_text.draw(&mut pass, part, coverage.is_some());
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
} else {
|
||||
pass.set_pipeline(pipeline);
|
||||
@@ -2898,6 +3002,10 @@ impl HudRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hud/coverage_tests.rs"]
|
||||
mod coverage_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3856,6 +3964,30 @@ texture=wide",
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
/// A bitmap font's `file` may be any texture, not only one under
|
||||
/// `textures/font`; the atlas packs it so the font can read its glyphs
|
||||
/// from there instead of copying them into the run-time region.
|
||||
#[test]
|
||||
fn a_bitmap_font_sheet_outside_font_is_packed() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-font-pack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
write_png(&root, "hud/sheet", 16, 8);
|
||||
let def = root.join("assets/minecraft/font/default.json");
|
||||
fs::create_dir_all(def.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&def,
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:hud/sheet.png","ascent":7,"chars":["ab"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas
|
||||
.sprites
|
||||
.get("hud/sheet")
|
||||
.expect("the font sheet is packed");
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 8));
|
||||
}
|
||||
|
||||
/// A content mod's GUI textures join the atlas under its namespace;
|
||||
/// its other textures and vanilla's names stay as they were.
|
||||
#[test]
|
||||
@@ -3975,6 +4107,52 @@ texture=wide",
|
||||
.unwrap();
|
||||
root
|
||||
}
|
||||
/// A pack layer that adds a font provider over the game's own resources:
|
||||
/// its `font/default.json` names `minecraft:font/test.png`, which only the
|
||||
/// game's root holds (`HudAtlas::build_over`'s `defaults`).
|
||||
fn pack_font_over_defaults(name: &str) -> (PathBuf, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"horizon-font-fallback-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let pack = base.join("pack");
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(pack.join("assets/minecraft/font")).unwrap();
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/font")).unwrap();
|
||||
fs::write(
|
||||
pack.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/test.png","ascent":7,"chars":["ab"]}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut pixels = vec![0u8; 16 * 8 * 4];
|
||||
for y in 0..8 {
|
||||
for x in (0..3).chain(8..13) {
|
||||
pixels[(y * 16 + x) * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
let mut encoder = png::Encoder::new(
|
||||
fs::File::create(vanilla.join("assets/minecraft/textures/font/test.png")).unwrap(),
|
||||
16,
|
||||
8,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
(pack, vanilla)
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_overlay_reads_font_sheets_from_the_game_below_it() {
|
||||
let (pack, vanilla) = pack_font_over_defaults("sheets");
|
||||
let atlas = HudAtlas::build_over(&pack, &vanilla).unwrap();
|
||||
let glyph = atlas.font.glyph('a');
|
||||
let _ = fs::remove_dir_all(pack.parent().unwrap());
|
||||
assert!(glyph.rect.is_some(), "text draws, not the missing glyph");
|
||||
assert!(glyph.advance > 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
|
||||
let root = two_glyph_font("atlas");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -230,7 +231,7 @@ pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
|
||||
/// pipeline per blend.
|
||||
pub(super) struct CitGlintPass {
|
||||
group: wgpu::BindGroup,
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
pipelines: Vec<Pipeline>,
|
||||
}
|
||||
|
||||
impl CitGlintPass {
|
||||
@@ -241,6 +242,7 @@ impl CitGlintPass {
|
||||
format: wgpu::TextureFormat,
|
||||
hud: &wgpu::BindGroupLayout,
|
||||
textures: &GlintTextures,
|
||||
coverage: bool,
|
||||
) -> Option<Self> {
|
||||
use wgpu::util::DeviceExt;
|
||||
if textures.is_empty() {
|
||||
@@ -381,7 +383,7 @@ impl CitGlintPass {
|
||||
.blends
|
||||
.iter()
|
||||
.map(|&blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some("CIT glint"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -409,7 +411,9 @@ impl CitGlintPass {
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the CIT glint's coverage")
|
||||
})
|
||||
.collect();
|
||||
Some(Self { group, pipelines })
|
||||
@@ -421,9 +425,10 @@ impl CitGlintPass {
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
blend: u8,
|
||||
vertices: std::ops::Range<u32>,
|
||||
coverage: bool,
|
||||
) {
|
||||
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_pipeline(pipeline.get(coverage));
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.draw(vertices, 0..1);
|
||||
}
|
||||
|
||||
Loaded 100 of 1368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user