Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 029de76f05 Vulkan, DX12: phase 1's solid draws made and counted on the GPU
Where multi-draws take their count from a buffer (MULTI_DRAW_INDIRECT_COUNT:
Vulkan 1.2+, DX12), the render thread no longer finds each phase 1 section's
facing runs nor uploads a draw per run: it lists one record per section, and
gpu_lists.wgsl's sides_draws writes one indirect draw per run the CPU would have
pushed (same count, base vertex and instance) into a call per pass and arena
page, drawn with multi_draw_indexed_indirect_count. Each call has room for
the most runs its sections' parts can have (Sides::part_runs). Same exclusions
as the flat path; HORIZON_GPU_LISTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:24:52 +01:00
Ludovic MarieandClaude Opus 5.5 5e5a2c74d2 Flat draws: phase 1's solid lists made on the GPU
In frames drawn flat (Metal at render distance 32, Intel Gen11/12 Vulkan),
the render thread walked every phase 1 section's sides each frame to find the
runs of quads that face the eye, then the flat plan copied them into one index
list. Now each section uploads a row once (where its quads start, how Sides
sorted them), and each frame phase 1 pushes one record per section: the GPU
(gpu_lists.wgsl's flatten_sides) finds the same runs as Sides::visible_from and
writes their indices into a call per pass and arena page, drawn indirect.
Phase 2, translucent quads, tiles, mirrors, shader pack terrain and shadows
keep the CPU lists. HORIZON_GPU_LISTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:24:42 +01:00
1235 changed files with 25085 additions and 178292 deletions

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`.
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
+5
View File
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
+2 -3
View File
@@ -96,9 +96,8 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
-9
View File
@@ -185,15 +185,6 @@ jobs:
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
run: |
set -euo pipefail
+1 -22
View File
@@ -15,8 +15,6 @@ name: launcher
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
# gh workflow run launcher --ref main -f dry_run=false -f force=true
@@ -28,7 +26,6 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
@@ -177,7 +174,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
@@ -243,18 +240,6 @@ jobs:
# The client starts: --help prints before any window or GPU is needed.
help() { "$client" --help > "$RUNNER_TEMP/help.txt" 2>&1 && head -3 "$RUNNER_TEMP/help.txt" && grep -q -- '--resources' "$RUNNER_TEMP/help.txt"; }
soft help
# The per-user Windows setup: the same binary under the setup's name installs into a scratch
# folder, and the copy it installs uninstalls itself. HKCU of the runner only.
if [[ "$RUNNER_OS" == "Windows" ]]; then
setup="$RUNNER_TEMP/setup-smoke"
cp "$launcher" "$RUNNER_TEMP/horizon-launcher-setup.exe"
"$RUNNER_TEMP/horizon-launcher-setup.exe" setup --quiet --dir "$setup"
installed="$setup/Horizon Launcher/horizon-launcher.exe"
[[ -f "$installed" ]] || { echo "::error::the setup copied no launcher"; exit 1; }
"$installed" uninstall --quiet
[[ ! -f "$installed" ]] || { echo "::error::uninstall left the launcher"; exit 1; }
rm -rf "$setup"
fi
rm -rf "$HORIZON_LAUNCHER_HOME"
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
@@ -290,12 +275,6 @@ jobs:
fi
fi
ls -l out
- name: Setup exe (Windows)
# The launcher's binary under another name: started with no arguments it shows the per-user
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
if: runner.os == 'Windows'
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
+15 -16
View File
@@ -55,7 +55,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `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)) |
@@ -104,10 +103,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
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
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
@@ -148,7 +143,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|---|---|
| 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` |
| 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/`, `ticket_levels.rs`; G: `world_loading.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` |
@@ -179,24 +174,28 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`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.
## Single player runs on a server thread
## The two single-player paths
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
Open `[server-thread]` issues list the gaps left.
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
Test the server thread when you touch player-facing gameplay:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
+1 -2
View File
@@ -96,8 +96,7 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
enough), then claim it. `/unclaim` gives it back.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
Generated
+5 -98
View File
@@ -329,12 +329,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -373,7 +367,6 @@ version = "0.1.0"
dependencies = [
"horizon-mods",
"serde_json",
"zip",
]
[[package]]
@@ -1217,12 +1210,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -1279,7 +1266,6 @@ name = "horizon-assets"
version = "0.1.0"
dependencies = [
"horizon-core",
"log",
"png",
"serde",
"serde_json",
@@ -1332,12 +1318,10 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1415,7 +1399,6 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1466,10 +1449,8 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
@@ -1479,9 +1460,7 @@ dependencies = [
"horizon-theme",
"horizon-threads",
"horizon-world",
"image-webp",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1498,7 +1477,6 @@ dependencies = [
"wgpu",
"winit",
"zip",
"zune-jpeg",
]
[[package]]
@@ -1506,7 +1484,6 @@ name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-protocol",
"horizon-world",
]
@@ -1518,18 +1495,6 @@ dependencies = [
"libm",
]
[[package]]
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"glam",
"png",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1577,32 +1542,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mod-api-gen"
version = "0.1.0"
dependencies = [
"heck",
"quote",
"serde",
"serde_json",
"syn 2.0.119",
]
[[package]]
name = "horizon-mod-api-gen-tests"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1635,7 +1574,6 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -1675,7 +1613,6 @@ dependencies = [
"horizon-core",
"horizon-world",
"ipconfig",
"log",
"serde_json",
"sha1",
"sha2",
@@ -1705,12 +1642,10 @@ dependencies = [
"ab_glyph",
"base64",
"chrono",
"getrandom 0.3.4",
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
@@ -1798,7 +1733,6 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
@@ -1945,16 +1879,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -2178,6 +2102,8 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
@@ -3176,12 +3102,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.41.0"
@@ -5199,8 +5119,10 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-mod-api-gen",
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
@@ -5362,18 +5284,3 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
+1 -10
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
@@ -12,14 +12,11 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
@@ -31,8 +28,6 @@ members = [
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mods",
"crates/horizon-mod-api-gen",
"crates/horizon-mod-api-gen/dispatch-tests",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
@@ -70,10 +65,6 @@ large_enum_variant = "allow"
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+13 -4
View File
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
#### One single-player path: remove the in-frame world
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
<details><summary>Technical</summary>
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -634,6 +634,16 @@ A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https:/
</details>
#### HDR monitors
Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
#### MSAA, anisotropic filtering, exclusive fullscreen
More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
@@ -894,7 +904,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
- **Discord Rich Presence** (v0.2.58): Your Discord profile can show that you are playing Horizon, with a switch to turn it off. [#1787](https://github.com/horizon-rust/horizon/issues/1787) · [#1788](https://github.com/horizon-rust/horizon/issues/1788)
- **Server dialogs and login screens** (v0.2.67): Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon. [#1720](https://github.com/horizon-rust/horizon/issues/1720) · [#1734](https://github.com/horizon-rust/horizon/issues/1734) · [#1735](https://github.com/horizon-rust/horizon/issues/1735)
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **HDR monitors** (v0.2.74): Output for HDR monitors: brighter highlights and richer colour where your screen supports it. [#1441](https://github.com/horizon-rust/horizon/issues/1441) · [#2021](https://github.com/horizon-rust/horizon/issues/2021) · [#2292](https://github.com/horizon-rust/horizon/issues/2292)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+12 -62
View File
@@ -14,15 +14,6 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -36,17 +27,6 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
@@ -61,17 +41,19 @@ trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with, and offers the logos as
instance icons.
The launcher shows the logo of the mod loader an instance plays with.
- `launcher/assets/logos/{fabric,quilt,neoforge,forge}.png` are the loaders' logos as the launcher
mockup on [playhorizon.dev/launcher](https://playhorizon.dev/launcher) shows them (horizon-site
`public/media/launcher/*.webp`, converted to PNG), each its project's own mark:
[Fabric](https://fabricmc.net/), [Quilt](https://quiltmc.org/) (its art is
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)),
[NeoForged](https://neoforged.net/) (its art, by Ridanisaurus, is
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)) and
[Minecraft Forge](https://minecraftforge.net/). They name the loader an instance runs.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
@@ -133,35 +115,3 @@ licensed under the MIT license:
> 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.
## BlueMap
`crates/horizon-client-render/src/bluemap.rs` reads the lowres tiles of a server's
[BlueMap](https://github.com/BlueMap-Minecraft/BlueMap) web map. It follows three of BlueMap's files:
`core/src/main/java/de/bluecolored/bluemap/core/map/lowres/LowresLayer.java` (`saveTile`) for the
tiles' pixel layout, `common/webapp/src/js/util/Utils.js` (`pathFromCoords`) for their paths, and
`core/src/main/java/de/bluecolored/bluemap/core/map/MapSettingsSerializer.java` for a map's
`settings.json`, at commit
[96ae320d1d39](https://github.com/BlueMap-Minecraft/BlueMap/tree/96ae320d1d39581c10058d8222f2290f2fcb5333).
BlueMap is licensed under the MIT license:
> Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
> Copyright (c) contributors
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in
> all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
> THE SOFTWARE.
-3
View File
@@ -10,8 +10,5 @@ description = "cargo horizon-mod new/build/run/check: make Horizon content mods
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
[dev-dependencies]
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[lints]
workspace = true
+7 -133
View File
@@ -1,8 +1,7 @@
//! `cargo horizon-mod`: make Horizon content mods (`docs/modpacks.md`): blocks,
//! items, data and assets, no code. Code mods are native (docs/native-mods.md).
//!
//! - `new <path> [--name "Name"]`: a content mod from the template; with
//! `--template native [--horizon <commit>]`, a native trait mod's bundle.
//! - `new <path> [--name "Name"]`: a content mod from the template.
//! - `build`: the `.hmod` in `target/horizon-mod/`, with the mod's `assets/`
//! and `data/`.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -33,7 +32,7 @@ fn main() -> ExitCode {
Some("run") => run(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] [--template content|native [--horizon <commit>]] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--name \"Name\"] | build | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -75,11 +74,6 @@ fn new(args: &[String]) -> Result<(), String> {
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let fill = |t: &str| t.replace("{id}", &id).replace("{name}", &name);
match flag(args, "--template") {
None | Some("content") => {}
Some("native") => return new_native(dir, &id, flag(args, "--horizon"), fill),
Some(_) => return Err("--template is content or native".into()),
}
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
let files = [
(
@@ -120,6 +114,11 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
if flag(args, "--template").is_some_and(|t| t != "content") {
return Err(
"only content mods are made here: code mods are native (docs/native-mods.md)".into(),
);
}
for (file, text) in files {
let file = fill(file);
let file = file.as_str();
@@ -138,67 +137,6 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
/// A native trait mod's bundle in `dir` (docs/native-mods.md, "Trait mods"): its
/// `horizon-mixin.toml` and an `[api]` crate with one hook. It is what the launcher
/// builds (`native-build <dir>`) and packs as `mixins/<id>/` of a `.hnmod`
/// (docs/api/mod-files.md). `horizon` pins the Horizon commit it is made for.
fn new_native(
dir: &Path,
id: &str,
horizon: Option<&str>,
fill: impl Fn(&str) -> String,
) -> Result<(), String> {
// `[mod] id`, as `horizon-native.json` needs it: the template keeps to its characters.
if id.len() > 64 {
return Err(format!("mod id {id}: 64 characters at most"));
}
let (compatibility, rev) = match horizon {
Some(commit) if horizon_mods::index::native::revision(commit) => (
format!("[compatibility]\nhorizon_revision = \"{commit}\"\n"),
format!(", rev = \"{commit}\""),
),
Some(commit) => {
return Err(format!(
"--horizon {commit}: a Horizon commit is 40 lowercase hex digits"
));
}
// Unpinned, it builds on the instance's own Horizon.
None => (
"# The Horizon commit it is made for; unpinned, it builds on the instance's own.\n\
# [compatibility]\n# horizon_revision = \"<40 hex digits>\"\n"
.to_owned(),
String::new(),
),
};
let files = [
(
"horizon-mixin.toml",
include_str!("../template/native/horizon-mixin.toml"),
),
(
"api/Cargo.toml",
include_str!("../template/native/api.toml"),
),
("api/src/lib.rs", include_str!("../template/native/lib.rs")),
(".gitignore", include_str!("../template/gitignore")),
];
for (file, text) in files {
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
let text = fill(text)
.replace("{compatibility}", &compatibility)
.replace("{rev}", &rev);
std::fs::write(&to, text).map_err(|e| format!("{}: {e}", to.display()))?;
}
println!(
"Created {} (native mod id {id}). Next: horizon-launcher native-build {} --instance <instance> --trust-native-code",
dir.display(),
dir.display()
);
Ok(())
}
/// Packs the content mod in the current folder into `target/horizon-mod/<id>.hmod`: the
/// manifest and the files in `assets/` and `data/`.
fn build() -> Result<PathBuf, String> {
@@ -334,68 +272,4 @@ mod tests {
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_native_template_packs_into_an_hnmod_the_registry_accepts() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("horizon-mod-native-{}", std::process::id()));
let path = dir.join("machine-mod");
let commit = "0123456789abcdef0123456789abcdef01234567";
let args = [
path.display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
commit.into(),
];
new(&args).unwrap();
let api = std::fs::read_to_string(path.join("api/Cargo.toml")).unwrap();
assert!(api.contains(&format!("rev = \"{commit}\"")), "{api}");
// Packed as the launcher packs a mod folder: the manifest, then the bundle under
// `mixins/<id>/` (docs/api/mod-files.md).
let manifest = serde_json::json!({
"schema": 1, "id": "machine_mod", "version": "0.1.0", "game": "26.3",
"horizon": commit, "mixins": "b".repeat(40),
});
let hnmod = dir.join("machine_mod.hnmod");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&hnmod).unwrap());
let options = zip::write::SimpleFileOptions::default();
zip.start_file("horizon-native.json", options).unwrap();
zip.write_all(manifest.to_string().as_bytes()).unwrap();
for file in [
"horizon-mixin.toml",
"api/Cargo.toml",
"api/src/lib.rs",
".gitignore",
] {
assert!(horizon_mods::index::native::allowed(file), "{file}");
zip.start_file(format!("mixins/machine_mod/{file}"), options)
.unwrap();
zip.write_all(&std::fs::read(path.join(file)).unwrap())
.unwrap();
}
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
dir.join("bad").display().to_string(),
"--template".into(),
"native".into(),
"--horizon".into(),
"main".into(),
];
assert!(new(&bad).unwrap_err().contains("40 lowercase hex"));
let free = dir.join("free");
new(&[
free.display().to_string(),
"--template".into(),
"native".into(),
])
.unwrap();
let mixin = std::fs::read_to_string(free.join("horizon-mixin.toml")).unwrap();
assert!(!mixin.contains("\nhorizon_revision"), "{mixin}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -1,9 +0,0 @@
[package]
name = "mod-{id}"
version = "0.1.0"
edition = "2024"
[dependencies]
# The launcher points this at the Horizon source it builds the game from. Any other
# dependency must be a crate and version in Horizon's Cargo.lock.
horizon-client = { git = "https://github.com/horizon-rust/horizon"{rev} }
@@ -1,9 +0,0 @@
# {name}: a native trait mod (docs/native-mods.md, "Trait mods"). Build it with
# `horizon-launcher native-build <this folder> --instance <instance> --trust-native-code`.
[mod]
id = "{id}"
version = "0.1.0"
{compatibility}
[api]
path = "api"
@@ -1,18 +0,0 @@
//! The trait mod: the launcher links this crate into the game and calls `hooks()`. Each
//! method of `Hooks` is one of Horizon's hook points; implement the ones the mod needs
//! (docs/native-mods.md, "Trait mods").
use horizon_client::mod_api::{Hooks, Host, PlayerLoggedInEvent, Text};
pub fn hooks() -> Box<dyn Hooks> {
Box::new(Mod)
}
struct Mod;
impl Hooks for Mod {
/// A player joined the server (NeoForge's `PlayerLoggedInEvent`): greet them.
fn player_logged_in(&self, host: &mut dyn Host, event: &mut PlayerLoggedInEvent<'_>) {
let line = format!("{id} says hello, {}!", event.name);
host.tell(event.player, Text::literal(line));
}
}
-1
View File
@@ -8,7 +8,6 @@ description = "Horizon version-specific model and resource decoding"
[dependencies]
horizon-core = { path = "../horizon-core" }
log = "0.4"
serde = "1.0.229"
serde_json = { version = "1.0.151", features = ["raw_value"] }
+4 -350
View File
@@ -1,10 +1,5 @@
//! Cuboid geometry in block units, including blockstate rotation and UV locking.
use crate::{
json::*,
model::*,
obj::{MtlTexture, ObjFace, ObjModel},
resolve::{ResolvedCuboidModel, ResolvedLoader},
};
use crate::{json::*, model::*, resolve::ResolvedCuboidModel};
mod transform;
pub use transform::BlockModelTransform;
@@ -18,40 +13,7 @@ pub struct ModelQuad {
pub material: Material,
pub tint_index: i32,
pub shade_direction_override: Option<Direction>,
/// The face's or element's `light_emission`.
pub light_emission: u8,
/// Least (block, sky) light the quad is drawn with: its emission, raised by `neoforge_data`.
pub light: [u8; 2],
/// `neoforge_data` colour (ARGB) multiplied into the quad's tint.
pub color: Option<i32>,
/// `neoforge_data` override of the model's ambient occlusion for this quad.
pub ambient_occlusion: Option<bool>,
pub render_type: Option<RenderType>,
/// False for a loader quad that takes no directional shading.
pub shade: bool,
}
/// NeoForge's `render_type` names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderType {
Solid,
Cutout,
CutoutMipped,
Translucent,
Tripwire,
}
impl RenderType {
/// Accepts a bare or `minecraft:` name; others (mods' own types) are `None`.
pub fn by_name(name: &str) -> Option<Self> {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"solid" => Some(Self::Solid),
"cutout" => Some(Self::Cutout),
"cutout_mipped" => Some(Self::CutoutMipped),
"translucent" => Some(Self::Translucent),
"tripwire" => Some(Self::Tripwire),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug)]
@@ -224,17 +186,6 @@ fn bake_elements(
skip_flat: bool,
) -> Result<Vec<ModelQuad>> {
let mut output = Vec::new();
bake_into(model, transform, skip_flat, &mut output)?;
Ok(output)
}
/// The elements, then what the model's loader adds.
fn bake_into(
model: &ResolvedCuboidModel,
transform: BlockModelTransform,
skip_flat: bool,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for element in model.elements() {
let element = element
.as_ref()
@@ -256,108 +207,11 @@ fn bake_into(
force_translucent: false,
});
output.push(bake_face(
element,
face,
*direction,
matrix,
material,
transform,
render_type,
element, face, *direction, matrix, material, transform,
)?);
}
}
match model.loader() {
Some(ResolvedLoader::Composite(children)) => {
for child in children {
bake_into(child, transform, skip_flat, output)?;
}
}
Some(ResolvedLoader::Obj {
model: Some(obj),
flip_v,
shade,
hidden,
}) => bake_obj(model, obj, *flip_v, *shade, hidden, transform, output)?,
Some(ResolvedLoader::Obj { model: None, .. }) | None => {}
}
Ok(())
}
/// Obj faces are unculled quads in block units; the blockstate transform moves them whole.
fn bake_obj(
model: &ResolvedCuboidModel,
obj: &ObjModel,
flip_v: bool,
shade: bool,
hidden: &std::collections::BTreeSet<String>,
transform: BlockModelTransform,
output: &mut Vec<ModelQuad>,
) -> Result<()> {
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
let slot = match obj.materials.get(&face.material) {
Some(MtlTexture::Sprite(sprite)) => {
output.push(obj_quad(
face,
Material {
sprite: identifier(sprite)?,
force_translucent: false,
},
flip_v,
shade,
render_type,
transform,
));
continue;
}
Some(MtlTexture::Slot(slot)) => slot,
None => &face.material,
};
let material = if slot.is_empty() {
None
} else {
model.material(slot)?
};
let material = material.cloned().unwrap_or(Material {
sprite: "minecraft:missingno".to_owned(),
force_translucent: false,
});
output.push(obj_quad(
face,
material,
flip_v,
shade,
render_type,
transform,
));
}
Ok(())
}
fn obj_quad(
face: &ObjFace,
material: Material,
flip_v: bool,
shade: bool,
render_type: Option<RenderType>,
transform: BlockModelTransform,
) -> ModelQuad {
let positions = face.vertices.map(|v| transform.position(v.position));
ModelQuad {
positions,
uv: face
.vertices
.map(|v| [v.uv[0], if flip_v { 1.0 - v.uv[1] } else { v.uv[1] }]),
facing: facing(&positions).unwrap_or(Direction::Up),
cull: None,
material,
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type,
shade,
}
Ok(output)
}
/// `FaceBakery.bakeQuad` for a quad with explicit corners and unit-atlas UVs
/// (`uv` in 0..16 texels), as `ItemModelGenerator` builds its layers.
@@ -376,7 +230,6 @@ pub fn bake_flat_face(
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
};
let face = Face {
cull: None,
@@ -384,8 +237,6 @@ pub fn bake_flat_face(
texture: String::new(),
uv: Some(uv),
rotation: 0,
light_emission: None,
neoforge_data: None,
};
bake_face(
&element,
@@ -394,7 +245,6 @@ pub fn bake_flat_face(
None,
material,
BlockModelTransform::new([0; 3], false),
None,
)
}
fn bake_face(
@@ -404,7 +254,6 @@ fn bake_face(
matrix: Option<ElementMatrix>,
material: Material,
transform: BlockModelTransform,
render_type: Option<RenderType>,
) -> Result<ModelQuad> {
let mut positions = corners(element.from, element.to, direction).map(|v| v.map(|c| c / 16.0));
if let (Some(rotation), Some(matrix)) = (&element.rotation, matrix) {
@@ -454,11 +303,6 @@ fn bake_face(
uv.swap(index, found);
}
}
let data = face
.neoforge_data
.unwrap_or_default()
.over(element.neoforge_data.unwrap_or_default());
let light_emission = face.light_emission.unwrap_or(element.light_emission);
Ok(ModelQuad {
positions,
uv,
@@ -467,196 +311,6 @@ fn bake_face(
material,
tint_index: face.tint_index,
shade_direction_override: element.shade_direction_override,
light_emission,
light: [data.block_light, data.sky_light].map(|l| l.unwrap_or(0).max(light_emission)),
color: data.color,
ambient_occlusion: data.ambient_occlusion,
render_type,
shade: true,
light_emission: element.light_emission,
})
}
#[cfg(test)]
mod loader_tests {
use super::*;
use crate::resolve::{AssetReader, ModelLibrary};
use std::{collections::BTreeMap, sync::Arc};
struct Files(BTreeMap<&'static str, &'static str>);
impl AssetReader for Files {
fn read_text(&self, location: &str) -> Option<String> {
self.0.get(location).map(|text| (*text).to_owned())
}
}
fn library(models: &[(&str, &str)], files: &[(&'static str, &'static str)]) -> ModelLibrary {
let models = models
.iter()
.map(|(name, json)| ((*name).to_owned(), CuboidModel::from_json(json).unwrap()))
.collect();
ModelLibrary::new(models)
.unwrap()
.with_assets(Arc::new(Files(files.iter().copied().collect())))
}
fn bake(library: &ModelLibrary, name: &str) -> Vec<ModelQuad> {
bake_block_model(
&library.resolve(name).unwrap(),
BlockModelTransform::new([0; 3], false),
)
.unwrap()
}
fn sprites(quads: &[ModelQuad]) -> Vec<&str> {
quads.iter().map(|q| q.material.sprite.as_str()).collect()
}
const CUBE: &str =
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"up":{"texture":"#all"}}}]}"##;
const COMPOSITE: &str = r#"{"loader":"neoforge:composite","textures":{"all":"block/shared"},
"children":{"zeta":{"parent":"t:cube","textures":{"all":"block/own"}},"alpha":{"parent":"t:cube"}}}"#;
#[test]
fn composite_bakes_children_in_document_order_with_slot_fallback() {
let library = library(&[("t:cube", CUBE), ("t:comp", COMPOSITE)], &[]);
let quads = bake(&library, "t:comp");
assert_eq!(
sprites(&quads),
["minecraft:block/own", "minecraft:block/shared"]
);
}
#[test]
fn composite_visibility_hides_a_child() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let library = library(&[("t:cube", CUBE), ("t:comp", &hidden)], &[]);
assert_eq!(
sprites(&bake(&library, "t:comp")),
["minecraft:block/shared"]
);
}
#[test]
fn an_inheriting_model_overrides_which_children_are_visible() {
let hidden =
COMPOSITE.replace(r#""children""#, r#""visibility":{"zeta":false},"children""#);
let shown = r#"{"parent":"t:hidden","visibility":{"zeta":true,"alpha":false}}"#;
let library = library(
&[("t:cube", CUBE), ("t:hidden", &hidden), ("t:shown", shown)],
&[],
);
assert_eq!(sprites(&bake(&library, "t:shown")), ["minecraft:block/own"]);
}
#[test]
fn loader_is_inherited_by_parent_after_own_elements() {
let derived = r##"{"parent":"t:comp","textures":{"all":"block/derived"},
"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{"down":{"texture":"#all"}}}]}"##;
let library = library(
&[
("t:cube", CUBE),
("t:comp", COMPOSITE),
("t:derived", derived),
],
&[],
);
let quads = bake(&library, "t:derived");
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(
sprites(&quads),
[
"minecraft:block/derived",
"minecraft:block/own",
"minecraft:block/derived"
]
);
}
#[test]
fn own_loader_wins_and_unknown_loader_adds_nothing() {
let other = r#"{"parent":"t:comp","loader":"somemod:thing"}"#;
let library = library(
&[("t:cube", CUBE), ("t:comp", COMPOSITE), ("t:other", other)],
&[],
);
assert!(bake(&library, "t:other").is_empty());
}
#[test]
fn composites_that_nest_through_themselves_are_an_error() {
let looping = r#"{"loader":"neoforge:composite","children":{"a":{"parent":"t:loop"}}}"#;
let library = library(&[("t:loop", looping)], &[]);
assert!(library.resolve("t:loop").is_err());
}
const OBJ: &str = "mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\nv 0 1 0\n\
vt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl top\nf 1/1 4/4 3/3 2/2\nusemtl side\nf 1/1 2/2 5/4\nusemtl bare\nf 1/1 2/2 5/4\n";
const MTL: &str = "newmtl top\nmap_Kd #lid\nnewmtl side\nmap_Kd #wall\nnewmtl bare\nKd 1 1 1\n";
const OBJ_FILES: [(&str, &str); 2] = [("t:models/x.obj", OBJ), ("t:models/m.mtl", MTL)];
const OBJ_MODEL: &str = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","flip_v":true,
"textures":{"lid":"block/lid","wall":"block/wall"}}"#;
#[test]
fn obj_faces_become_quads_with_materials_and_flipped_v() {
let flat = OBJ_MODEL.replace(r#""flip_v":true,"#, "");
let library = library(&[("t:o", OBJ_MODEL), ("t:flat", &flat)], &OBJ_FILES);
let quads = bake(&library, "t:o");
assert_eq!(
sprites(&quads),
[
"minecraft:block/lid",
"minecraft:block/wall",
"minecraft:missingno"
]
);
assert_eq!(quads[0].facing, Direction::Up);
assert_eq!(quads[1].facing, Direction::South);
assert_eq!(quads[0].positions[1], [0.0, 0.0, 1.0]);
assert_eq!(quads[0].uv[0], [0.0, 1.0]);
assert_eq!(quads[0].uv[2], [1.0, 0.0]);
// A triangle repeats its last vertex.
assert_eq!(quads[1].positions[3], quads[1].positions[2]);
assert_eq!(quads[1].uv[3], quads[1].uv[2]);
assert!(quads.iter().all(|q| q.cull.is_none() && q.shade));
assert_eq!(bake(&library, "t:flat")[0].uv[0], [0.0, 0.0]);
}
#[test]
fn obj_shade_quads_false_and_mtl_override() {
let unshaded = r#"{"loader":"neoforge:obj","model":"t:models/x.obj","shade_quads":false,
"mtl_override":"t:models/o.mtl","textures":{"s":"block/s"}}"#;
let files = [
("t:models/x.obj", OBJ),
("t:models/o.mtl", "newmtl top\nmap_Kd #s\n"),
];
let library = library(&[("t:u", unshaded)], &files);
let quads = bake(&library, "t:u");
assert!(quads.iter().all(|q| !q.shade));
assert_eq!(quads[0].material.sprite, "minecraft:block/s");
}
#[test]
fn missing_or_broken_obj_bakes_nothing() {
let missing = library(&[("t:o", OBJ_MODEL)], &[]);
assert!(bake(&missing, "t:o").is_empty());
let broken = library(
&[("t:o", OBJ_MODEL)],
&[("t:models/x.obj", "v 0 0 0\nf 1 2 3\n")],
);
assert!(bake(&broken, "t:o").is_empty());
}
#[test]
fn obj_visibility_skips_the_objects_a_model_hides() {
let two = "v 0 0 0\nv 1 0 0\nv 1 0 1\nv 0 0 1\no a\nusemtl m\nf 1 2 3 4\no b\nf 4 3 2 1\n";
let files = [("t:models/two.obj", two)];
let base =
r#"{"loader":"neoforge:obj","model":"t:models/two.obj","textures":{"m":"block/m"}}"#;
let hiding = r#"{"parent":"t:base","visibility":{"b":false}}"#;
let restoring = r#"{"parent":"t:hiding","visibility":{"b":true}}"#;
let library = library(
&[
("t:base", base),
("t:hiding", hiding),
("t:restoring", restoring),
],
&files,
);
assert_eq!(bake(&library, "t:base").len(), 2);
let quads = bake(&library, "t:hiding");
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].facing, Direction::Down);
assert_eq!(bake(&library, "t:restoring").len(), 2);
}
}
-54
View File
@@ -17,7 +17,6 @@ use std::{
};
mod component_predicate;
mod foreign;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -432,17 +431,6 @@ impl ItemModelNode {
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let kind = ident(required(m, "type")?)?;
if kind == "minecraft:condition" {
let property = ident(required(m, "property")?)?;
if !property.starts_with("minecraft:") {
return Ok(Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::Custom(property),
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
});
}
}
Ok(match kind.as_str() {
"minecraft:empty" => Self::Empty,
"minecraft:model" => Self::Model(ModelLeaf {
@@ -463,21 +451,12 @@ impl ItemModelNode {
.collect::<Result<_>>()?,
transformation: optional_transformation(m)?,
},
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
Self::decode(required(m, "on_false")?)?,
optional_transformation(m)?,
),
"minecraft:condition" => Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::decode(m)?,
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
},
"minecraft:select" if foreign::property_is_foreign(m)? => {
// No case can match a property Horizon cannot read.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
}
"minecraft:select" => {
let transformation = optional_transformation(m)?;
let property = SelectProperty::decode(m)?;
@@ -505,7 +484,6 @@ impl ItemModelNode {
}
}
"minecraft:range_dispatch" => {
let foreign_property = foreign::property_is_foreign(m)?;
let mut entries = list(required(m, "entries")?)?
.iter()
.map(|e| {
@@ -518,17 +496,6 @@ impl ItemModelNode {
.collect::<Result<Vec<_>>>()?;
// `Comparator.comparingDouble`: Double.compare, a total order.
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
if foreign_property {
// A property Horizon cannot read is 0.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
let chosen = last_index_less_or_equal(&entries, 0.0)
.map(|i| entries.swap_remove(i).1)
.or(fallback);
return Ok(foreign::wrap(
chosen.unwrap_or(Self::Empty),
optional_transformation(m)?,
));
}
Self::RangeDispatch {
transformation: optional_transformation(m)?,
property: NumericProperty::decode(m)?,
@@ -539,21 +506,12 @@ impl ItemModelNode {
.transpose()?,
}
}
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
// The mod's own renderer is not run: the base model stands in.
Self::Model(ModelLeaf {
model: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
tints: Vec::new(),
})
}
"minecraft:special" => Self::Special {
base: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
model: SpecialModel::decode(required(m, "model")?)?,
},
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
_ => return Err(err("unknown item model type")),
})
}
@@ -645,8 +603,6 @@ impl TintSource {
"minecraft:firework" => Self::Firework(default()?),
"minecraft:potion" => Self::Potion(default()?),
"minecraft:team" => Self::Team(default()?),
// A mod's own tint source, which its Java code computes: untinted here.
other if foreign::is_foreign(other) => Self::Constant(-1),
_ => return Err(err("unknown item tint source")),
})
}
@@ -658,7 +614,6 @@ impl TintSource {
#[derive(Clone, Debug, PartialEq)]
pub enum ConditionProperty {
Custom(String),
CustomModelData(i32),
UsingItem,
Broken,
@@ -991,10 +946,6 @@ pub struct Context<'a> {
/// Host services the resolver cannot derive from data alone.
pub trait ItemEnv {
/// A mod's namespaced conditional property, false when no mod supplies it.
fn item_model_property(&self, _property: &str, _stack: &StackView) -> bool {
false
}
/// `PotionContents.getColorOr` for the potion/effects part: the mixed
/// effect colour, or `None` when the contents have no visible effects.
fn potion_effect_color(&self, _potion_contents: &Value) -> Option<i32> {
@@ -1348,9 +1299,6 @@ impl Resolve<'_> {
fn condition(&self, property: &ConditionProperty) -> bool {
let owner = self.ctx.owner;
match property {
ConditionProperty::Custom(property) => {
self.env.item_model_property(property, self.stack)
}
ConditionProperty::CustomModelData(i) => {
self.component("minecraft:custom_model_data")
.and_then(|d| custom_data_at(d, "flags", *i))
@@ -1769,7 +1717,5 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
Err(low as usize)
}
#[cfg(test)]
mod foreign_tests;
#[cfg(test)]
mod tests;
@@ -1,114 +0,0 @@
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
use super::{
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
optional_transformation,
};
use crate::{json::error as err, math::Mat4};
use serde_json::{Map, Value};
/// Keys a mod's node usually keeps its real model under, in order of preference.
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
pub fn is_foreign(id: &str) -> bool {
!id.starts_with("minecraft:")
}
/// A composite carries the transformation a collapsed node had.
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
match transformation {
None => node,
Some(_) => ItemModelNode::Composite {
models: vec![node],
transformation,
},
}
}
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
let property = m
.get("property")
.ok_or_else(|| err("missing property"))
.and_then(ident)?;
Ok(is_foreign(&property))
}
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
let tints = get(m, "tints")
.and_then(|t| list(t).ok())
.map(|t| {
t.iter()
.filter_map(|t| TintSource::decode(t).ok())
.collect()
})
.unwrap_or_default();
ItemModelNode::Model(ModelLeaf {
model,
transformation: optional_transformation(m).ok().flatten(),
tints,
})
}
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
if kind == "neoforge:fluid_container" {
return fluid_container(m);
}
for key in MODEL_KEYS {
match m.get(key) {
Some(nested @ Value::Object(_)) => {
if let Ok(node) = ItemModelNode::decode(nested) {
return wrap(node, optional_transformation(m).ok().flatten());
}
}
Some(Value::String(id)) => {
if let Ok(id) = identifier(id) {
return leaf(id, m);
}
}
_ => {}
}
}
let by_suffix = m
.iter()
.filter(|(k, _)| k.ends_with("_model"))
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
match by_suffix {
Some(id) => leaf(id, m),
None => ItemModelNode::Empty,
}
}
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
let textures = m.get("textures").and_then(|t| object(t).ok());
let texture = |key: &str| {
textures
.and_then(|t| t.get(key))
.and_then(Value::as_str)
.and_then(|id| identifier(id).ok())
};
let mut layers = vec![texture("base")];
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
layers.push(texture("cover"));
}
let mut models: Vec<ItemModelNode> = layers
.into_iter()
.flatten()
.map(|model| {
ItemModelNode::Model(ModelLeaf {
model,
transformation: None,
tints: Vec::new(),
})
})
.collect();
let transformation = optional_transformation(m).ok().flatten();
match models.len() {
0 => ItemModelNode::Empty,
1 => wrap(models.remove(0), transformation),
_ => ItemModelNode::Composite {
models,
transformation,
},
}
}
@@ -1,185 +0,0 @@
use super::*;
fn node(json: &str) -> Result<ItemModelNode> {
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
}
fn model_id(n: &ItemModelNode) -> &str {
match n {
ItemModelNode::Model(leaf) => &leaf.model,
other => panic!("not a model: {other:?}"),
}
}
#[test]
fn node_with_a_model_id_draws_that_model() {
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
.unwrap();
assert_eq!(model_id(&n), "mod:item/thing");
let ItemModelNode::Model(leaf) = n else {
unreachable!()
};
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/base");
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/a");
}
#[test]
fn node_with_a_nested_node_decodes_it() {
let n = node(
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/boots");
let n = node(
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
)
.unwrap();
let ItemModelNode::Composite {
models,
transformation,
} = n
else {
panic!("a transformation wraps the nested node")
};
assert!(transformation.is_some());
assert_eq!(model_id(&models[0]), "mod:item/inner");
}
#[test]
fn node_with_nothing_usable_is_empty() {
assert!(matches!(
node(r#"{"type":"mod:facade"}"#).unwrap(),
ItemModelNode::Empty
));
assert!(matches!(
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
ItemModelNode::Empty
));
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
}
#[test]
fn unknown_vanilla_names_still_fail() {
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
for json in [
format!(
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
),
] {
assert!(node(&json).is_err(), "{json}");
}
assert!(
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
.is_err()
);
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
}
#[test]
fn condition_property_is_false() {
let n = node(
r#"{"type":"minecraft:condition","property":"mod:flag",
"on_true":{"type":"minecraft:model","model":"a:yes"},
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
// A mod's property stays a condition its mod answers per stack
// (`item_model_property`); with no mod answering it, `on_false` is drawn.
let ItemModelNode::Condition {
property: ConditionProperty::Custom(property),
on_false,
..
} = &n
else {
panic!("not a custom condition: {n:?}")
};
assert_eq!(property, "mod:flag");
assert_eq!(model_id(on_false), "a:no");
}
#[test]
fn select_property_takes_the_fallback_or_nothing() {
let n = node(
r#"{"type":"minecraft:select","property":"mod:level",
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:other");
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn range_property_reads_zero() {
let entry = |t: f32, m: &str| {
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
};
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
let make = |entries: &[String], fallback: &str| {
node(&format!(
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
entries.join(",")
))
.unwrap()
};
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
assert_eq!(model_id(&n), "a:fb");
let n = make(
&[
entry(0.0, "a:zero"),
entry(-1.0, "a:neg"),
entry(0.5, "a:half"),
],
fallback,
);
assert_eq!(model_id(&n), "a:zero");
let n = make(&[entry(0.2, "a:one")], "");
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn special_type_keeps_its_base_model() {
let n = node(
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/cube");
let n =
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
.unwrap();
assert!(matches!(n, ItemModelNode::Special { .. }));
}
#[test]
fn fluid_container_draws_its_base_without_the_fluid() {
let n = node(
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "minecraft:item/bucket");
let n = node(
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
)
.unwrap();
let ItemModelNode::Composite { models, .. } = n else {
panic!("base and cover")
};
assert_eq!(models.len(), 2);
assert!(matches!(
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
ItemModelNode::Empty
));
}
@@ -77,13 +77,6 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
}
impl SpecialModel {
/// A special model type of another namespace, which Horizon has no renderer for.
pub fn is_foreign(v: &Value) -> bool {
v.get("type")
.and_then(Value::as_str)
.and_then(|t| super::identifier(t).ok())
.is_some_and(|t| super::foreign::is_foreign(&t))
}
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
@@ -18,96 +18,3 @@ fn display_context_names_round_trip() {
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
}
}
#[test]
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
assert_eq!(
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
TintSource::Constant(-1)
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
#[test]
fn custom_condition_is_evaluated_per_stack_at_branch_selection() {
struct Env;
impl ItemEnv for Env {
fn item_model_property(&self, property: &str, stack: &StackView) -> bool {
assert_eq!(property, "example:charged");
stack.count > 1 && stack.patch.get("example:charged") == Some(&Some(Value::Bool(true)))
}
}
let definition = ClientItem::from_json(
r#"{"model":{
"type":"minecraft:condition", "property":"example:charged",
"on_true":{"type":"minecraft:model","model":"example:item/charged"},
"on_false":{"type":"minecraft:model","model":"example:item/idle"}
}}"#,
)
.unwrap();
let mut models = ItemModels::default();
models.definitions.insert("example:tool".into(), definition);
models.prototypes.insert(
"example:tool".into(),
BTreeMap::from([(
"minecraft:item_model".into(),
Value::String("example:tool".into()),
)]),
);
let context = Context {
display: DisplayContext::Gui,
owner: None,
level: None,
seed: 0,
};
let mut stack = StackView::new("example:tool", 1);
let chosen = |stack: &StackView, env: &dyn ItemEnv| {
let resolved = models.resolve(stack, &context, env);
assert_eq!(resolved.layers.len(), 1);
let LayerContent::Model { model } = &resolved.layers[0].content else {
panic!("expected model leaf")
};
model.clone()
};
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack
.patch
.insert("example:charged".into(), Some(Value::Bool(true)));
assert_eq!(chosen(&stack, &Env), "example:item/idle");
stack.count = 2;
assert_eq!(chosen(&stack, &Env), "example:item/charged");
assert_eq!(chosen(&stack, &NoEnv), "example:item/idle");
stack.patch.insert("example:charged".into(), None);
assert_eq!(chosen(&stack, &Env), "example:item/idle");
}
#[test]
fn custom_condition_validates_its_name_and_both_branches() {
let node = |property: &str, on_true: Value| {
ItemModelNode::decode(&serde_json::json!({
"type": "minecraft:condition", "property": property,
"on_true": on_true, "on_false": {"type": "minecraft:empty"}
}))
};
let empty = serde_json::json!({"type": "minecraft:empty"});
for name in [
"minecraft:unknown",
"unknown",
"example:Bad",
"bad name:charged",
] {
assert!(node(name, empty.clone()).is_err(), "{name}");
}
assert!(node("example:charged", Value::Null).is_err());
assert!(matches!(
node("minecraft:damaged", empty.clone()).unwrap(),
ItemModelNode::Condition {
property: ConditionProperty::Damaged,
..
}
));
assert!(matches!(node("example:charged", empty).unwrap(),
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
}
-33
View File
@@ -87,39 +87,6 @@ pub(crate) fn object(raw: &RawValue) -> Result<Object<'_>> {
}
Ok(serde_json::from_str(raw.get())?)
}
/// The members of an object in document order, which `object` loses.
pub(crate) fn ordered_object(raw: &RawValue) -> Result<Vec<(String, &RawValue)>> {
struct Members<'a>(Vec<(String, &'a RawValue)>);
impl<'de> serde::Deserialize<'de> for Members<'de> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Members<'de>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut members: Vec<(String, &'de RawValue)> = Vec::new();
while let Some(entry) = map.next_entry::<String, &'de RawValue>()? {
members.retain(|(name, _)| *name != entry.0);
members.push(entry);
}
Ok(Members(members))
}
}
deserializer.deserialize_map(Visitor)
}
}
if !raw.get().starts_with('{') {
return Err(error("expected object"));
}
Ok(serde_json::from_str::<Members>(raw.get())?.0)
}
pub(crate) fn array(raw: &RawValue) -> Result<Vec<&RawValue>> {
Ok(serde_json::from_str(raw.get())?)
}
-1
View File
@@ -9,7 +9,6 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod obj;
pub mod palette;
pub mod resolve;
+1 -172
View File
@@ -5,7 +5,7 @@
//! does; render preparation must diagnose these before baking.
use crate::json::*;
use serde_json::value::RawValue;
use std::{collections::BTreeMap, sync::Arc};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
@@ -59,92 +59,11 @@ pub enum TextureSlot {
#[derive(Clone, Debug, PartialEq)]
pub struct CuboidModel {
pub parent: Option<String>,
pub loader: Option<Loader>,
pub ambient_occlusion: Option<bool>,
pub gui_light: Option<GuiLight>,
pub textures: BTreeMap<String, TextureSlot>,
pub elements: Option<Vec<Option<Element>>>,
pub display: Option<ItemTransforms>,
/// NeoForge's `render_type`, without a `minecraft:` prefix.
pub render_type: Option<String>,
/// A model's `visibility`: an obj object or composite child name to shown; absent is shown.
pub visibility: BTreeMap<String, bool>,
}
/// A custom model loader's payload; a model inherits its parent's loader unless it names its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Loader {
/// `neoforge:composite`: children in document order.
Composite {
children: Vec<(String, Arc<CuboidModel>)>,
/// Child name to shown; a child absent from the map is shown.
visibility: BTreeMap<String, bool>,
},
/// `neoforge:obj`: `model` and `mtl_override` are asset locations.
Obj {
model: String,
flip_v: bool,
shade_quads: Option<bool>,
mtl_override: Option<String>,
},
/// A loader this decoder does not know: it adds no geometry.
Unknown(String),
}
/// NeoForge's `neoforge_data` of an element or a face; a face's keys win.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct NeoforgeData {
pub block_light: Option<u8>,
pub sky_light: Option<u8>,
/// ARGB.
pub color: Option<i32>,
pub ambient_occlusion: Option<bool>,
}
impl NeoforgeData {
/// `self` over `base`, key by key.
pub fn over(self, base: Self) -> Self {
Self {
block_light: self.block_light.or(base.block_light),
sky_light: self.sky_light.or(base.sky_light),
color: self.color.or(base.color),
ambient_occlusion: self.ambient_occlusion.or(base.ambient_occlusion),
}
}
fn decode(raw: &RawValue) -> Result<Self> {
let obj = object(raw)?;
let light = |key: &str| -> Result<Option<u8>> {
Ok(obj
.get(key)
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8))
};
// Some mods spell both lights as one `light_emission`.
let emission = light("light_emission")?;
let color = match obj.get("color") {
Some(raw) if raw.get().starts_with('"') => {
let text = string(raw)?;
let text = text.trim();
let hex = text
.strip_prefix('#')
.or_else(|| text.strip_prefix("0x"))
.or_else(|| text.strip_prefix("0X"));
match hex {
Some(hex) => u32::from_str_radix(hex, 16).ok().map(|v| v as i32),
None => text.parse::<u32>().ok().map(|v| v as i32),
}
}
Some(raw) => Some(integer(raw)?),
None => None,
};
Ok(Self {
block_light: light("block_light")?.or(emission),
sky_light: light("sky_light")?.or(emission),
color,
ambient_occlusion: obj
.get("ambient_occlusion")
.map(|r| boolean(r))
.transpose()?,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
@@ -154,7 +73,6 @@ pub struct Element {
pub rotation: Option<ElementRotation>,
pub shade_direction_override: Option<Direction>,
pub light_emission: u8,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Face {
@@ -164,9 +82,6 @@ pub struct Face {
pub uv: Option<[f32; 4]>,
/// Normalized counterclockwise quadrants, in the range 0..4.
pub rotation: u8,
/// A face-level `light_emission`, which wins over its element's.
pub light_emission: Option<u8>,
pub neoforge_data: Option<NeoforgeData>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
@@ -239,11 +154,6 @@ impl CuboidModel {
} else {
Some(identifier(&parent)?)
};
let loader = obj
.get("loader")
.filter(|raw| raw.get() != "null")
.map(|raw| Loader::decode(raw, &obj))
.transpose()?;
let ambient_occlusion = obj
.get("ambientocclusion")
.map(|r| boolean(r))
@@ -275,83 +185,15 @@ impl CuboidModel {
.get("display")
.map(|r| ItemTransforms::decode(r))
.transpose()?;
let render_type = obj
.get("render_type")
.filter(|raw| raw.get().starts_with('"'))
.map(|raw| string(raw))
.transpose()?
.map(|name| name.strip_prefix("minecraft:").unwrap_or(&name).to_owned());
Ok(Self {
parent,
loader,
ambient_occlusion,
gui_light,
textures,
elements,
display,
render_type,
visibility: visibility(&obj)?,
})
}
/// The models this one needs besides itself: its parent and its composite children's parents.
pub fn referenced_models(&self) -> Vec<String> {
let mut names: Vec<String> = self.parent.iter().cloned().collect();
if let Some(Loader::Composite { children, .. }) = &self.loader {
for (_, child) in children {
names.extend(child.referenced_models());
}
}
names
}
}
fn visibility(obj: &Object<'_>) -> Result<BTreeMap<String, bool>> {
let mut visibility = BTreeMap::new();
if let Some(raw) = obj.get("visibility") {
for (key, raw) in object(raw)? {
visibility.insert(key, boolean(raw)?);
}
}
Ok(visibility)
}
impl Loader {
fn decode(raw: &RawValue, obj: &Object<'_>) -> Result<Self> {
let name = primitive_string(raw)?;
match name.as_str() {
"neoforge:composite" => {
let mut children = Vec::new();
if let Some(raw) = obj.get("children") {
for (name, raw) in ordered_object(raw)? {
children.push((name, Arc::new(CuboidModel::decode(raw)?)));
}
}
Ok(Self::Composite {
children,
visibility: visibility(obj)?,
})
}
"neoforge:obj" => {
let optional = |key: &str| {
obj.get(key)
.filter(|raw| raw.get() != "null")
.map(|raw| primitive_string(raw))
.transpose()
};
Ok(Self::Obj {
model: identifier(&optional("model")?.ok_or_else(|| error("missing model"))?)?,
flip_v: obj
.get("flip_v")
.map(|r| boolean(r))
.transpose()?
.unwrap_or(false),
shade_quads: obj.get("shade_quads").map(|r| boolean(r)).transpose()?,
mtl_override: optional("mtl_override")?
.map(|name| identifier(&name))
.transpose()?,
})
}
_ => Ok(Self::Unknown(name)),
}
}
}
fn nullable<T>(raw: &RawValue, decode: impl FnOnce(&RawValue) -> Result<T>) -> Result<Option<T>> {
if raw.get() == "null" {
@@ -431,10 +273,6 @@ impl Element {
rotation,
shade_direction_override,
light_emission: light_emission as u8,
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
@@ -468,15 +306,6 @@ impl Face {
texture,
uv,
rotation: (rotation / 90) as u8,
light_emission: obj
.get("light_emission")
.map(|r| integer(r))
.transpose()?
.map(|v| v.clamp(0, 15) as u8),
neoforge_data: obj
.get("neoforge_data")
.map(|r| NeoforgeData::decode(r))
.transpose()?,
})
}
}
-183
View File
@@ -1,183 +0,0 @@
//! Wavefront OBJ and MTL text, as obj-loader models use them.
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ObjVertex {
/// Block units.
pub position: [f32; 3],
/// Texture coordinates as written, 0..1 across the sprite.
pub uv: [f32; 2],
}
/// One quad; a triangle repeats its last vertex.
#[derive(Clone, Debug, PartialEq)]
pub struct ObjFace {
/// Empty before any `usemtl`.
pub material: String,
/// The `o` (else `g`) it belongs to, which a model's `visibility` names.
pub object: String,
pub vertices: [ObjVertex; 4],
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ParsedObj {
pub mtllib: Option<String>,
pub faces: Vec<ObjFace>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MtlTexture {
/// `map_Kd #name`: a texture slot of the model.
Slot(String),
/// `map_Kd namespace:path`: a sprite.
Sprite(String),
}
/// A parsed OBJ with its materials.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ObjModel {
pub faces: Vec<ObjFace>,
pub materials: BTreeMap<String, MtlTexture>,
}
pub fn parse_obj(text: &str) -> Result<ParsedObj, String> {
let mut positions: Vec<[f32; 3]> = Vec::new();
let mut uvs: Vec<[f32; 2]> = Vec::new();
let mut parsed = ParsedObj::default();
let mut material = String::new();
let mut object = String::new();
for (number, line) in text.lines().enumerate() {
let mut words = line.split_whitespace();
let Some(keyword) = words.next() else {
continue;
};
let at = |message: &str| format!("line {}: {message}", number + 1);
match keyword {
_ if keyword.starts_with('#') => {}
"v" => positions.push(floats::<3>(words).ok_or_else(|| at("bad vertex"))?),
"vt" => {
let mut words = words;
let u = words.next().and_then(|w| w.parse().ok());
let v = words.next().map_or(Some(0.0), |w| w.parse().ok());
let (Some(u), Some(v)) = (u, v) else {
return Err(at("bad texture coordinate"));
};
uvs.push([u, v]);
}
"mtllib" => parsed.mtllib = words.next().map(str::to_owned),
"usemtl" => material = words.next().unwrap_or("").to_owned(),
"o" | "g" => object = words.next().unwrap_or("").to_owned(),
"f" => {
let mut corners = Vec::new();
for word in words {
let mut parts = word.split('/');
let position = index(parts.next(), positions.len())
.ok_or_else(|| at("bad vertex index"))?;
let uv = match parts.next() {
None | Some("") => [0.0; 2],
some => {
uvs[index(some, uvs.len()).ok_or_else(|| at("bad texture index"))?]
}
};
corners.push(ObjVertex {
position: positions[position],
uv,
});
}
if corners.len() < 3 {
return Err(at("face needs three vertices"));
}
let mut push = |vertices: [ObjVertex; 4]| {
parsed.faces.push(ObjFace {
material: material.clone(),
object: object.clone(),
vertices,
});
};
if corners.len() <= 4 {
let last = corners[corners.len() - 1];
push(std::array::from_fn(|i| {
corners.get(i).copied().unwrap_or(last)
}));
} else {
for i in 1..corners.len() - 1 {
push([corners[0], corners[i], corners[i + 1], corners[i + 1]]);
}
}
}
_ => {}
}
}
Ok(parsed)
}
fn floats<'a, const N: usize>(mut words: impl Iterator<Item = &'a str>) -> Option<[f32; N]> {
let mut result = [0.0; N];
for value in &mut result {
*value = words.next()?.parse().ok()?;
}
Some(result)
}
/// A 1-based index, or a negative one counted from the end.
fn index(word: Option<&str>, len: usize) -> Option<usize> {
let value: i64 = word?.parse().ok()?;
let index = if value < 0 {
len as i64 + value
} else {
value - 1
};
(0..len as i64).contains(&index).then_some(index as usize)
}
/// Material name to its `map_Kd`; other statements are ignored.
pub fn parse_mtl(text: &str) -> BTreeMap<String, MtlTexture> {
let mut materials = BTreeMap::new();
let mut current = None;
for line in text.lines() {
let mut words = line.split_whitespace();
match words.next() {
Some("newmtl") => current = words.next().map(str::to_owned),
Some("map_Kd") => {
let (Some(name), Some(value)) = (&current, words.last()) else {
continue;
};
let texture = match value.strip_prefix('#') {
Some(slot) => MtlTexture::Slot(slot.to_owned()),
None => MtlTexture::Sprite(value.to_owned()),
};
materials.insert(name.clone(), texture);
}
_ => {}
}
}
materials
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quads_triangles_and_negative_indices() {
let text = "mtllib a.mtl\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\n\
usemtl first\nf 1/1/1 2/2/1 3/3/1 4/4/1\nusemtl second\nf -4//1 -3//1 -2//1\n";
let parsed = parse_obj(text).unwrap();
assert_eq!(parsed.mtllib.as_deref(), Some("a.mtl"));
assert_eq!(parsed.faces.len(), 2);
assert_eq!(parsed.faces[0].material, "first");
assert_eq!(parsed.faces[0].vertices[2].uv, [1.0, 1.0]);
let triangle = &parsed.faces[1];
assert_eq!(triangle.material, "second");
assert_eq!(triangle.vertices[0].position, [0.0, 0.0, 0.0]);
assert_eq!(triangle.vertices[3], triangle.vertices[2]);
assert_eq!(triangle.vertices[2].position, [1.0, 1.0, 0.0]);
}
#[test]
fn bad_indices_are_errors() {
assert!(parse_obj("v 0 0 0\nf 1 2 3\n").is_err());
assert!(parse_obj("v 0 0 0\nv 1 0 0\nf 1 2\n").is_err());
}
#[test]
fn mtl_maps_slots_and_sprites() {
let materials =
parse_mtl("newmtl a\nKd 1 1 1\nmap_Kd #top\nnewmtl b\nmap_Kd mod:block/x\nnewmtl c\n");
assert_eq!(materials["a"], MtlTexture::Slot("top".into()));
assert_eq!(materials["b"], MtlTexture::Sprite("mod:block/x".into()));
assert!(!materials.contains_key("c"));
}
}
+22 -280
View File
@@ -1,45 +1,18 @@
//! Cuboid parent inheritance and texture-slot resolution.
use crate::{
json::*,
model::*,
obj::{ObjModel, parse_mtl, parse_obj},
};
use crate::{json::*, model::*};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
sync::Arc,
};
pub const MISSING_MODEL: &str = "minecraft:builtin/missing";
/// `ItemModelGenerator.GENERATED_ITEM_MODEL_ID`.
pub const GENERATED_MODEL: &str = "minecraft:builtin/generated";
/// Composite children nest through parents that may name the composite again.
const MAX_LOADER_DEPTH: u8 = 8;
/// Reads asset files that models point at (`namespace:path` is `assets/namespace/path`).
pub trait AssetReader: Send + Sync {
fn read_text(&self, location: &str) -> Option<String>;
}
/// Decoded models are immutable and their inherited geometry is shared.
pub struct ModelLibrary {
models: BTreeMap<String, Arc<CuboidModel>>,
missing: Arc<CuboidModel>,
assets: Option<Arc<dyn AssetReader>>,
/// Keyed by obj and mtl override; `None` is a file that failed, already warned about.
objs: Mutex<BTreeMap<(String, Option<String>), Option<Arc<ObjModel>>>>,
}
/// What a model's loader adds to its own elements.
#[derive(Clone, Debug)]
pub enum ResolvedLoader {
/// The shown children, resolved on their own, in order.
Composite(Vec<ResolvedCuboidModel>),
Obj {
/// `None` when the file failed to load.
model: Option<Arc<ObjModel>>,
flip_v: bool,
shade: bool,
/// Objects the model chain's `visibility` hides.
hidden: BTreeSet<String>,
},
}
#[derive(Clone, Debug)]
pub struct ResolvedCuboidModel {
@@ -49,17 +22,10 @@ pub struct ResolvedCuboidModel {
pub textures: BTreeMap<String, Material>,
/// Cyclic and dangling aliases are absent from `textures`.
pub unresolved_textures: BTreeSet<String>,
/// NeoForge's `render_type`; the leaf-most model that sets one wins.
pub render_type: Option<String>,
geometry: Option<Arc<CuboidModel>>,
generated: bool,
loader: Option<ResolvedLoader>,
}
impl ResolvedCuboidModel {
/// Geometry from a custom loader, baked after the elements.
pub fn loader(&self) -> Option<&ResolvedLoader> {
self.loader.as_ref()
}
/// Whether the top geometry is `builtin/generated`'s layer extrusion
/// (no model closer to the leaf has elements of its own).
pub fn uses_item_generator(&self) -> bool {
@@ -100,7 +66,6 @@ impl ModelLibrary {
fn item_generator_model() -> CuboidModel {
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: Some(GuiLight::Front),
textures: BTreeMap::from([(
@@ -109,8 +74,6 @@ impl ModelLibrary {
)]),
elements: None,
display: None,
render_type: None,
visibility: BTreeMap::new(),
}
}
pub fn new(models: BTreeMap<String, CuboidModel>) -> Result<Self> {
@@ -137,15 +100,8 @@ impl ModelLibrary {
Ok(Self {
models: decoded,
missing,
assets: None,
objs: Mutex::default(),
})
}
/// Where `neoforge:obj` models find their files.
pub fn with_assets(mut self, assets: Arc<dyn AssetReader>) -> Self {
self.assets = Some(assets);
self
}
/// This library plus `extra` models; an `extra` model replaces a
/// same-named one, but not the built-in missing and generated models.
/// Every existing model is shared, not copied.
@@ -166,15 +122,13 @@ impl ModelLibrary {
Ok(Self {
models,
missing: Arc::clone(&self.missing),
assets: self.assets.clone(),
objs: Mutex::default(),
})
}
/// Follow the complete parent chain. A missing resource uses the built-in
/// model; a cyclic chain is rejected, as native discovery excludes it.
/// The returned model can outlive this library without copying its elements.
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_name(name, None)
self.resolve_chain(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
@@ -209,40 +163,28 @@ impl ModelLibrary {
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_name(name, Some(child))
self.resolve_chain(name, Some(child))
}
fn resolve_name(
fn resolve_chain(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let next = Some(identifier(name)?);
self.resolve_chain(Vec::new(), Vec::new(), next, &BTreeMap::new(), child, 0)
}
/// `chain` and `names` start with models that are not in the library (a
/// composite child); `next` is the parent to follow. `fallback` slots lose
/// to any slot the chain defines.
fn resolve_chain(
&self,
mut chain: Vec<Arc<CuboidModel>>,
mut names: Vec<String>,
mut next: Option<String>,
fallback: &BTreeMap<String, TextureSlot>,
child: Option<&BTreeMap<String, TextureSlot>>,
depth: u8,
) -> Result<ResolvedCuboidModel> {
if depth > MAX_LOADER_DEPTH {
return Err(error("composite models nest too deeply"));
}
let mut name = identifier(name)?;
let mut visited = BTreeSet::new();
while let Some(name) = next.take() {
let mut chain = Vec::new();
let mut names = Vec::new();
loop {
if !visited.insert(name.clone()) {
return Err(error("cyclic model parent"));
}
let model = self.models.get(&name).unwrap_or(&self.missing);
chain.push(Arc::clone(model));
names.push(name);
next = model.parent.clone();
chain.push(model);
names.push(name.clone());
match &model.parent {
Some(parent) => name = parent.clone(),
None => break,
}
}
let ambient_occlusion = chain
.iter()
@@ -252,8 +194,10 @@ impl ModelLibrary {
.iter()
.find_map(|m| m.gui_light)
.unwrap_or(GuiLight::Side);
let render_type = chain.iter().find_map(|m| m.render_type.clone());
let geometry = chain.iter().find(|m| m.elements.is_some()).map(Arc::clone);
let geometry = chain
.iter()
.find(|m| m.elements.is_some())
.map(|m| Arc::clone(m));
let generated = chain
.iter()
.zip(&names)
@@ -272,68 +216,13 @@ impl ModelLibrary {
};
// Resolve aliases only after applying every child override. A reference
// inherited from a parent binds to the child's final slot definition.
let mut slots: BTreeMap<String, &TextureSlot> =
fallback.iter().map(|(k, v)| (k.clone(), v)).collect();
for model in chain.iter().rev() {
let mut slots = BTreeMap::new();
for model in chain.into_iter().rev() {
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
}
if let Some(child) = child {
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
}
let loader = match chain.iter().find_map(|m| m.loader.as_ref()) {
Some(Loader::Composite {
children,
visibility,
}) => {
let inherited: BTreeMap<String, TextureSlot> = slots
.iter()
.map(|(k, v)| (k.clone(), (*v).clone()))
.collect();
// A model closer to the leaf overrides the loader's own map.
let mut visibility = visibility.clone();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
let mut resolved = Vec::new();
for (child_name, child_model) in children {
if visibility.get(child_name) == Some(&false) {
continue;
}
resolved.push(self.resolve_chain(
vec![Arc::clone(child_model)],
vec![format!("{}#{child_name}", names[0])],
child_model.parent.clone(),
&inherited,
None,
depth + 1,
)?);
}
Some(ResolvedLoader::Composite(resolved))
}
Some(Loader::Obj {
model,
flip_v,
shade_quads,
mtl_override,
}) => {
// Leaf wins: a model closer to the leaf overrides its parents' entries.
let mut visibility = BTreeMap::new();
for model in chain.iter().rev() {
visibility.extend(model.visibility.iter().map(|(k, v)| (k.clone(), *v)));
}
Some(ResolvedLoader::Obj {
model: self.load_obj(&names[0], model, mtl_override.as_deref()),
flip_v: *flip_v,
shade: shade_quads.unwrap_or(true),
hidden: visibility
.into_iter()
.filter(|(_, shown)| !shown)
.map(|(name, _)| name)
.collect(),
})
}
Some(Loader::Unknown(_)) | None => None,
};
let mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -375,61 +264,12 @@ impl ModelLibrary {
display,
textures,
unresolved_textures,
render_type,
geometry,
generated,
loader,
})
}
/// Parse an obj and its materials once; a failure warns once and yields no geometry.
fn load_obj(&self, label: &str, model: &str, mtl: Option<&str>) -> Option<Arc<ObjModel>> {
let key = (model.to_owned(), mtl.map(str::to_owned));
let mut cache = self.objs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = cache.get(&key) {
return found.clone();
}
let loaded = self.read_obj(model, mtl);
if let Err(message) = &loaded {
log::warn!("Horizon: obj model {label} ({model}): {message}");
}
let loaded = loaded.ok().map(Arc::new);
cache.insert(key, loaded.clone());
loaded
}
fn read_obj(&self, model: &str, mtl: Option<&str>) -> std::result::Result<ObjModel, String> {
let assets = self.assets.as_ref().ok_or("no asset source")?;
let text = assets.read_text(model).ok_or("file not found")?;
let parsed = parse_obj(&text)?;
let mtl = match (mtl, &parsed.mtllib) {
(Some(location), _) => Some(location.to_owned()),
(None, Some(file)) if file.contains(':') => Some(file.clone()),
(None, Some(file)) => {
let (namespace, path) = model.split_once(':').unwrap_or(("minecraft", model));
let directory = path.rsplit_once('/').map_or("", |(dir, _)| dir);
Some(if directory.is_empty() {
format!("{namespace}:{file}")
} else {
format!("{namespace}:{directory}/{file}")
})
}
(None, None) => None,
};
let materials = match mtl {
Some(location) => match assets.read_text(&location) {
Some(text) => parse_mtl(&text),
None => {
log::warn!("Horizon: obj material file {location} not found");
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
Ok(ObjModel {
faces: parsed.faces,
materials,
})
}
}
/// The native missing-model cube uses slot names without a leading '#'.
pub fn missing_model() -> CuboidModel {
let faces = Direction::ALL
@@ -443,15 +283,12 @@ pub fn missing_model() -> CuboidModel {
texture: "missingno".to_owned(),
uv: Some([0.0, 0.0, 16.0, 16.0]),
rotation: 0,
light_emission: None,
neoforge_data: None,
}),
)
})
.collect();
CuboidModel {
parent: None,
loader: None,
ambient_occlusion: None,
gui_light: None,
elements: Some(vec![Some(Element {
@@ -461,12 +298,10 @@ pub fn missing_model() -> CuboidModel {
rotation: None,
shade_direction_override: None,
light_emission: 0,
neoforge_data: None,
})]),
display: Some(ItemTransforms {
contexts: std::array::from_fn(|_| DisplayTransform::Inherit),
}),
render_type: None,
textures: BTreeMap::from([
(
"particle".to_owned(),
@@ -480,7 +315,6 @@ pub fn missing_model() -> CuboidModel {
}),
),
]),
visibility: BTreeMap::new(),
}
}
@@ -734,96 +568,4 @@ mod tests {
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
const LIGHTS: &str = r##"{"textures":{"a":"block/a"},"render_type":"minecraft:cutout","elements":[
{"from":[0,0,0],"to":[16,16,16],"light_emission":4,"neoforge_data":{"block_light":9,"color":-65536,"ambient_occlusion":false},
"faces":{"north":{"texture":"#a","neoforge_data":{"block_light":2,"sky_light":15,"unknown":1}},
"south":{"texture":"#a","light_emission":11},
"up":{"texture":"#a"}}}]}"##;
#[test]
fn neoforge_data_of_a_face_overrides_its_element_key_by_key() {
use crate::bake::bake_block_model;
let library = library(&[("block/lights", LIGHTS)]);
let model = library.resolve("block/lights").unwrap();
let quads =
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false)).unwrap();
let by = |d| quads.iter().find(|q| q.facing == d).unwrap();
let north = by(Direction::North);
assert_eq!(north.light, [4, 15]);
assert_eq!(north.color, Some(-65536));
assert_eq!(north.ambient_occlusion, Some(false));
assert_eq!(by(Direction::South).light, [11, 11]);
assert_eq!(by(Direction::South).light_emission, 11);
assert_eq!(by(Direction::Up).light, [9, 4]);
assert!(
quads
.iter()
.all(|q| q.render_type == Some(crate::bake::RenderType::Cutout))
);
}
#[test]
fn vanilla_models_bake_without_extra_face_data() {
use crate::bake::bake_block_model;
let library = library(&[(
"block/plain",
r##"{"textures":{"a":"block/a"},"elements":[{"from":[0,0,0],"to":[16,16,16],"light_emission":6,"faces":{"up":{"texture":"#a"}}}]}"##,
)]);
let model = library.resolve("block/plain").unwrap();
let quad = &bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))
.unwrap()[0];
assert_eq!((quad.light, quad.light_emission), ([6, 6], 6));
assert_eq!(
(quad.color, quad.ambient_occlusion, quad.render_type),
(None, None, None)
);
}
#[test]
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
let library = library(&[
("block/base", r#"{"render_type":"translucent"}"#),
("block/mid", r#"{"parent":"block/base"}"#),
(
"block/leaf",
r#"{"parent":"block/mid","render_type":"minecraft:cutout_mipped"}"#,
),
("block/bare", r#"{"parent":"block/mid"}"#),
]);
let render = |name| library.resolve(name).unwrap().render_type;
assert_eq!(render("block/leaf").as_deref(), Some("cutout_mipped"));
assert_eq!(render("block/bare").as_deref(), Some("translucent"));
assert_eq!(render("block/absent"), None);
assert_eq!(
crate::bake::RenderType::by_name("minecraft:tripwire"),
Some(crate::bake::RenderType::Tripwire)
);
assert_eq!(crate::bake::RenderType::by_name("mod:glow"), None);
}
#[test]
fn neoforge_data_colour_and_legacy_emission_spellings() {
let model = CuboidModel::from_json(
r##"{"elements":[{"from":[0,0,0],"to":[1,1,1],"faces":{
"up":{"texture":"#a","neoforge_data":{"color":"#80FF0000","light_emission":7}},
"down":{"texture":"#a","neoforge_data":{"color":4294967295,"block_light":99}}}}]}"##,
)
.unwrap();
let elements = model.elements.unwrap();
let faces = &elements[0].as_ref().unwrap().faces;
let up = faces[&Direction::Up]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(up.color, up.block_light, up.sky_light),
(Some(0x80FF0000u32 as i32), Some(7), Some(7))
);
let down = faces[&Direction::Down]
.as_ref()
.unwrap()
.neoforge_data
.unwrap();
assert_eq!(
(down.color, down.block_light, down.sky_light),
(Some(-1), Some(15), None)
);
}
}
+2 -60
View File
@@ -14,8 +14,7 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -103,8 +102,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -180,8 +177,6 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -212,7 +207,6 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -262,7 +256,6 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -826,53 +819,12 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -896,9 +848,6 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1058,10 +1007,7 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
thread::spawn(move || read(reader, writer, bot, track));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1079,10 +1025,6 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+19 -97
View File
@@ -125,11 +125,7 @@ impl Filter {
Some(w) => (w, true),
None => (word, false),
};
// An allow entry with no letters would allow every word (an empty prefix).
let runs = normalise::word(word);
if !runs.is_empty() {
f.allowed.push((runs, prefix));
}
f.allowed.push((normalise::word(word), prefix));
continue;
}
let (kind, word) = match line.as_bytes()[0] {
@@ -243,19 +239,8 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
/// Epoch milliseconds.
pub until: i64,
}
#[derive(Default, Serialize, Deserialize)]
@@ -327,38 +312,12 @@ impl Guard {
)
}
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -384,24 +343,15 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
Action::Muted { until }
} else {
Action::Refused
@@ -427,7 +377,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.active(now));
self.mutes.retain(|_, m| m.until > now);
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -474,34 +424,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+6 -82
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted(id, NOW), None);
assert_eq!(g.muted_until(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,83 +190,15 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted(5, now).is_some());
assert!(again.muted_until(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
assert_eq!(again.muted_until(5, now), None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
assert_eq!(reloaded.muted_until(5, now), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
@@ -319,11 +251,3 @@ fn a_message_costs_little() {
}
assert!(start.elapsed().as_millis() < 2000, "{:?}", start.elapsed());
}
#[test]
fn an_allow_line_without_letters_allows_nothing() {
for line in ["!*", "!-*", "!", "!-"] {
let f = Filter::new(&format!("nigger\n{line}\n"));
assert!(f.matches("nigger"), "{line}");
}
}
+5 -111
View File
@@ -215,7 +215,7 @@ fn untrusted_http_url(url: &str) -> bool {
})
}
/// `ClickEvent.CODEC` plus the legacy `value` field TextComponentHoverAndClickEventFix
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
@@ -249,13 +249,6 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
Some("custom") => Some(crate::text::ClickEvent::Custom {
id: crate::identifier::parse_identifier(&text("id")?)?,
payload: e
.get("payload")
.filter(|value| !value.is_null())
.map(crate::scoreboard::json_to_tag),
}),
_ => None,
}
}
@@ -264,7 +257,10 @@ fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| hex_color(c));
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
style.bold = style_flag(o.get("bold"));
@@ -307,13 +303,6 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style
}
/// `TextColor.parseColor` for `#...`: `Integer.parseInt(rest, 16)` (so a sign
/// and any digit count are accepted), kept only within `0..=0xFFFFFF`.
pub(crate) fn hex_color(c: &str) -> Option<u32> {
let value = i64::from_str_radix(c.strip_prefix('#')?, 16).ok()?;
(0..=0xFF_FFFF).contains(&value).then_some(value as u32)
}
/// `Style.shadow_color`: a packed ARGB int or normalized RGBA array.
pub(crate) fn shadow_color(value: &Value) -> Option<u32> {
if let Some(packed) = value.as_i64() {
@@ -496,33 +485,6 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn hex_color_follows_text_color_parse_color() {
for bad in [
"#1000000",
"#FFFFFFFF",
"#-FF",
"#",
"#zz",
"FFFFFF",
"#99999999999",
] {
assert_eq!(hex_color(bad), None, "{bad}");
}
for (good, want) in [
("#FFFFFF", 0xFFFFFF),
("#000000", 0),
("#F", 15),
("#+FF", 255),
("#-0", 0),
] {
assert_eq!(hex_color(good), Some(want), "{good}");
}
let out = serde_json::json!({"text": "A", "color": "#1000000"});
let text = to_text(&out, &mut |_| Text::empty());
assert_eq!(text.spans[0].style.color, None);
}
#[test]
fn shadow_color_accepts_packed_and_rgba_styles() {
let packed = serde_json::json!({"text": "A", "shadow_color": -16711936});
@@ -623,63 +585,6 @@ mod tests {
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn custom_click_events_keep_payloads_and_inherit_to_inline_objects() {
use crate::text::ClickEvent;
use horizon_world::nbt::Tag;
for (payload, expected) in [
(None, None),
(Some(serde_json::Value::Null), None),
(Some(serde_json::json!(42)), Some(Tag::Int(42))),
(
Some(serde_json::json!({"shop": "potions", "amount": 3})),
Some(Tag::Compound(
[
("amount".into(), Tag::Int(3)),
("shop".into(), Tag::String("potions".into())),
]
.into_iter()
.collect(),
)),
),
] {
let mut event = serde_json::json!({"action": "custom", "id": "test:shop"});
if let Some(payload) = payload {
event["payload"] = payload;
}
let component = serde_json::json!({
"text": "", "click_event": event,
"extra": [{"object": "atlas", "sprite": "minecraft:item/potion"}, "Shop"]
});
let text = to_text(&component, &mut |_| Text::empty());
for (_, style) in text.chars() {
assert_eq!(
style.click,
Some(ClickEvent::Custom {
id: "test:shop".into(),
payload: expected.clone(),
})
);
}
let encoded = text.component();
let decoded = to_text(&encoded, &mut |_| Text::empty());
assert_eq!(decoded, text);
if expected.is_none() {
assert!(!encoded.to_string().contains("\"payload\""));
}
}
for id in ["Bad ID", "Uppercase:shop"] {
let text = to_text(
&serde_json::json!({
"text": "Shop", "click_event": {"action": "custom", "id": id}
}),
&mut |_| Text::empty(),
);
assert_eq!(text.spans[0].style.click, None);
}
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
@@ -719,15 +624,4 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+12 -51
View File
@@ -125,51 +125,19 @@ pub const RULES: &[Rule] = &[
b("universal_anger", "mobs", false),
b("water_source_conversion", "updates", true),
];
/// GameRules.MAX_MINECART_SPEED (26.3), available with minecart improvements.
pub const MAX_MINECART_SPEED: Rule = i("max_minecart_speed", "misc", 8, 1, 1000);
pub const MINECART_IMPROVEMENTS: &str = "minecraft:minecart_improvements";
pub fn available_rules(minecart_improvements: bool) -> impl Iterator<Item = &'static Rule> {
RULES
.iter()
.chain(minecart_improvements.then_some(&MAX_MINECART_SPEED))
}
pub fn rule(name: &str) -> Option<&'static Rule> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
available_rules(true).find(|r| r.name == name)
RULES.iter().find(|r| r.name == name)
}
/// Current values (defaults unless set).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameRules {
values: BTreeMap<&'static str, Value>,
minecart_improvements: bool,
}
impl GameRules {
pub fn with_features(features: &[String]) -> Self {
Self {
minecart_improvements: features.iter().any(|f| f == MINECART_IMPROVEMENTS),
..Self::default()
}
}
/// GameRules.copy(enabledFeatures): keep available values, add defaults for
/// newly enabled rules and discard rules absent from the target feature set.
pub fn copy_with_features(&self, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
for (&name, &value) in &self.values {
rules.set(name, value);
}
rules
}
pub fn available_rules(&self) -> impl Iterator<Item = &'static Rule> + use<> {
available_rules(self.minecart_improvements)
}
pub fn get(&self, name: &str) -> Option<Value> {
let r = rule(name)?;
if r.name == MAX_MINECART_SPEED.name && !self.minecart_improvements {
return None;
}
Some(self.values.get(r.name).copied().unwrap_or(r.default))
}
pub fn bool(&self, name: &str) -> bool {
@@ -192,9 +160,7 @@ impl GameRules {
}
/// Set a rule; false if the name or value type is wrong.
pub fn set(&mut self, name: &str, value: Value) -> bool {
let Some(r) = rule(name).filter(|r| self.get(r.name).is_some()) else {
return false;
};
let Some(r) = rule(name) else { return false };
let ok = match (r.default, value) {
(Value::Bool(_), Value::Bool(_)) => true,
(Value::Int(_), Value::Int(v)) => (r.min..=r.max).contains(&v),
@@ -210,7 +176,8 @@ impl GameRules {
/// Every rule's value by name without namespace, as `save::Level` holds
/// them (booleans as 0 or 1).
pub fn by_name(&self) -> BTreeMap<String, i32> {
self.available_rules()
RULES
.iter()
.map(|r| {
let v = match self.values.get(r.name).copied().unwrap_or(r.default) {
Value::Bool(b) => i32::from(b),
@@ -222,8 +189,8 @@ impl GameRules {
}
/// GameRuleMap codec: `{data: {"minecraft:<rule>": value...}, DataVersion}`.
pub fn to_nbt(&self) -> NamedTag {
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = self
.available_rules()
let data: Vec<(horizon_world::nbt::JavaString, Tag)> = RULES
.iter()
.map(|r| {
let v = self.values.get(r.name).copied().unwrap_or(r.default);
let tag = match v {
@@ -245,14 +212,11 @@ impl GameRules {
}
}
pub fn from_nbt(root: &Tag) -> Self {
Self::from_nbt_with_features(root, &[])
}
pub fn from_nbt_with_features(root: &Tag, features: &[String]) -> Self {
let mut rules = Self::with_features(features);
let mut rules = Self::default();
let Some(data) = root.get("data") else {
return rules;
};
for r in rules.available_rules() {
for r in RULES {
let key = format!("minecraft:{}", r.name);
let value = data.get(&key).or_else(|| data.get(r.name));
match (r.default, value) {
@@ -268,18 +232,15 @@ impl GameRules {
rules
}
pub fn load(world: &Path) -> Self {
Self::load_with_features(world, &[])
}
pub fn load_with_features(world: &Path, features: &[String]) -> Self {
let Ok(bytes) = fs::read(world.join("data/minecraft/game_rules.dat")) else {
return Self::with_features(features);
return Self::default();
};
let Ok(raw) = Compression::Gzip.decode(&bytes, 16 << 20) else {
return Self::with_features(features);
return Self::default();
};
match nbt::read_named(&raw, Limits::default()) {
Ok((root, _)) => Self::from_nbt_with_features(&root.tag, features),
Err(_) => Self::with_features(features),
Ok((root, _)) => Self::from_nbt(&root.tag),
Err(_) => Self::default(),
}
}
pub fn save(&self, world: &Path) -> crate::Result<()> {
-18
View File
@@ -79,27 +79,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/hi_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hi_in.json")
}
"assets/horizon/lang/id_id.json" => {
include_str!("../../horizon-client/assets/horizon/lang/id_id.json")
}
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_pt.json")
}
"assets/horizon/lang/ur_pk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ur_pk.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
+7 -4
View File
@@ -107,7 +107,10 @@ pub fn style_from_json(v: &Value) -> Style {
let mut s = Style::default();
let Some(o) = v.as_object() else { return s };
if let Some(c) = o.get("color").and_then(Value::as_str) {
s.color = text::named_color(c).or_else(|| component::hex_color(c));
s.color = text::named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
s.shadow_color = o.get("shadow_color").and_then(component::shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
@@ -1338,9 +1341,9 @@ fn decompose(c: &Comp) -> (serde_json::Map<String, Value>, Vec<Comp>) {
}
"color" => {
let color = v.as_str().map(|s| match s.strip_prefix('#') {
Some(_) => match component::hex_color(s) {
Some(rgb) => format!("#{rgb:06X}"),
None => s.to_owned(),
Some(hex) => match u32::from_str_radix(hex, 16) {
Ok(rgb) => format!("#{rgb:06X}"),
Err(_) => s.to_owned(),
},
None => s.to_owned(),
});
+2 -36
View File
@@ -39,7 +39,7 @@ pub fn named_color(name: &str) -> Option<u32> {
COLORS.iter().find(|c| c.1 == name).map(|c| c.2)
}
/// Click actions carried by text components.
/// Click actions carried by locally constructed text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
@@ -53,11 +53,6 @@ pub enum ClickEvent {
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// `ClickEvent.Custom`: a server-defined action with an optional NBT payload.
Custom {
id: String,
payload: Option<horizon_world::nbt::Tag>,
},
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -246,28 +241,6 @@ impl Text {
t.source = Some(Value::Object(c).into());
t
}
/// Component.translatableWithFallback(key, fallback, args): `translate`, with `fallback` as
/// the template when the language has no `key`.
pub fn translate_with_fallback(key: &str, fallback: &str, args: &[Text]) -> Self {
let template = lang::current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned());
let mut t = format_translation(&template, args);
let mut c = Map::new();
c.insert("translate".into(), key.into());
c.insert("fallback".into(), fallback.into());
if !args.is_empty() {
c.insert("with".into(), args.iter().map(Text::argument).collect());
}
t.source = Some(Value::Object(c).into());
t
}
/// CommonComponents.optionNameValue: "Caption: value" joined as the language joins them
/// (`options.generic_value`: "%s : %s" in French, "%s:%s" in Chinese). English's "%s: %s"
/// when the language has no such key, as without prepared resources.
pub fn option_name_value(caption: Text, value: Text) -> Self {
Self::translate_with_fallback("options.generic_value", "%s: %s", &[caption, value])
}
/// A translatable message whose arguments are plain strings.
pub fn tr(key: &str, args: &[&dyn std::fmt::Display]) -> Self {
let args: Vec<Text> = args.iter().map(|a| Text::literal(a.to_string())).collect();
@@ -566,7 +539,7 @@ const STYLE_KEYS: [&str; 11] = [
"insertion",
"font",
];
/// `Style.Serializer`: the fields a style sets.
/// Style.Serializer: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(c) = style.color {
@@ -608,13 +581,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::Custom { id, payload } => {
let mut event = json!({ "action": "custom", "id": id });
if let Some(payload) = payload {
event["payload"] = crate::scoreboard::tag_to_json(payload);
}
event
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
+2 -37
View File
@@ -12,48 +12,13 @@ pub fn uuid_text(u: [i32; 4]) -> String {
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let mut hex = [0u8; 32];
let mut len = 0;
for b in s.bytes() {
if b == b'-' {
continue;
}
if !b.is_ascii_hexdigit() || len >= 32 {
return None;
}
hex[len] = b;
len += 1;
}
if len != 32 {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let hex = std::str::from_utf8(&hex).ok()?;
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_from_text_rejects_multibyte_and_non_hex() {
assert_eq!(uuid_from_text("€€€€€€€€€€aa"), None);
assert_eq!(uuid_from_text("€€€€€€€€€€aa----"), None);
assert_eq!(uuid_from_text("+0000001-0000-0000-0000-000000000000"), None);
assert_eq!(uuid_from_text("00000000-0000-0000-0000-00000000000g"), None);
assert_eq!(uuid_from_text(""), None);
}
#[test]
fn uuid_from_text_parses_hex_and_dashes() {
let u = [0x01234567, -0x76543211, 0x01234567, -0x76543211];
let text = uuid_text(u);
assert_eq!(uuid_from_text(&text), Some(u));
let undashed: String = text.chars().filter(|c| *c != '-').collect();
assert_eq!(uuid_from_text(&undashed), Some(u));
}
}
+2 -203
View File
@@ -182,38 +182,13 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -243,18 +218,6 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -285,37 +248,6 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -339,6 +271,7 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -745,140 +678,6 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
-10
View File
@@ -20,8 +20,6 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -134,10 +132,6 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
@@ -225,7 +219,3 @@ path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
[[example]]
name = "chunk_encoding_bench"
path = "../horizon-client/examples/chunk_encoding_bench.rs"
-10
View File
@@ -7,9 +7,6 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
@@ -43,10 +40,6 @@ miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
half = "2"
png = "0.18"
# Project icons served by Modrinth are often WebP or JPEG rather than PNG, and
# the pack browser showed nothing for those. Both are small pure-Rust decoders.
image-webp = { version = "0.2", default-features = false }
zune-jpeg = { version = "0.4", default-features = false, features = ["std"] }
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
@@ -75,9 +68,6 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
-7
View File
@@ -1,7 +0,0 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
@@ -1,220 +0,0 @@
//! Original synthetic capability regression; no game resources or player world.
//! Run with fresh HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME,
//! HORIZON_NO_AUDIO=1 and HORIZON_LAUNCHER_NO_SELF_UPDATE=1.
use horizon_client_render::{gfx::Renderer, graphics::GraphicsApi};
use std::{io::Write, sync::Arc};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
const COLOUR: &str = "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n";
// Reading the actual shadow sampler activates the shadow pass, rather than just
// placing a shadow program in the archive and assuming the pass runs.
const SHADOW_READ: &str = "#version 120\nuniform sampler2D shadowtex0;\n\
void main() { gl_FragColor = vec4(vec3(texture2D(shadowtex0, vec2(0.5)).r), 1.0); }\n";
fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_shaders::Pack {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut write = |name: &str, text: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
};
write("shaders.properties", properties);
write("final.vsh", VERTEX);
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
write("gbuffers_entities.vsh", VERTEX);
write("gbuffers_entities.fsh", COLOUR);
if let Some(shadow) = shadow {
write("shadow.vsh", VERTEX);
write("shadow.fsh", shadow);
}
horizon_shaders::Pack::from_zip_bytes(
"original-shadow-ownership",
zip.finish().unwrap().into_inner(),
)
.unwrap()
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let white = [255; 4];
let panorama = Arc::new(horizon_client_render::gfx::panorama::Faces::colour([0; 4]));
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
1,
1,
1,
&[],
&[],
(&white, 1, 1),
(&white, 1, 1),
(&white, 1, 1),
(&white, 1),
panorama,
None,
None,
GraphicsApi::Vulkan,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]);
assert!(
!renderer.pack_entity_shadows(),
"selected pack without a compiled post"
);
let cases = [
("full", "", Some(COLOUR), true, false, true),
(
"player-only",
"shadowEntities=false\nshadowPlayer=true",
Some(COLOUR),
true,
false,
false,
),
// Block-entity shadows alone must not suppress a mob's blob.
(
"blocks-only",
"shadowEntities=false",
Some(COLOUR),
true,
false,
false,
),
(
"disabled",
"shadow.enabled=false",
Some(COLOUR),
true,
false,
false,
),
("missing-program", "", None, true, false, false),
(
"failed-translation",
"",
Some("#version 120\nvoid main() { gl_FragColor = deliberately_undefined; }"),
true,
false,
false,
),
("failed-pipeline", "", Some(COLOUR), true, true, false),
(
"unused-shadow-program",
"",
Some(COLOUR),
false,
false,
false,
),
(
"forced-shadow-program",
"shadow.enabled=true",
Some(COLOUR),
false,
false,
true,
),
];
for dimension in [0, -1, 1] {
for (name, properties, shadow, reads_shadow, break_pipeline, expected) in cases {
let pack = pack(properties, shadow, reads_shadow);
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let mut post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&renderer.pack_inputs(),
);
if name == "failed-translation" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("skipped")));
} else if name == "missing-program" {
assert!(post.shadow_entities.is_none());
assert!(post.notes.iter().any(|note| note.contains("no program")));
} else if break_pipeline {
// A translated stage can still fail device pipeline creation. Inject a
// missing entry point into only the entity caster; terrain/block stages
// and the settings stay valid. PackPost must report the compiled result.
post.shadow_entities
.as_mut()
.unwrap()
.program
.as_mut()
.unwrap()
.vertex
.entry_points[0]
.name = "deliberately_missing_main".into();
} else {
assert!(post.notes.is_empty(), "{name}: {:?}", post.notes);
}
let (_, notes) = renderer
.set_shader_pack(dimension, Some(post), &[])
.unwrap();
if break_pipeline {
assert!(
notes.iter().any(|note| note.contains("skipped")),
"{notes:?}"
);
} else if name != "missing-program" && name != "failed-translation" {
assert!(
!notes.iter().any(|note| note.contains("skipped")),
"{name}: {notes:?}"
);
}
assert_eq!(
renderer.pack_entity_shadows(),
expected,
"{name}, dimension {dimension}: {notes:?}"
);
assert!(!renderer.native_shadow());
println!("{name}, dimension {dimension}: replaces_mob_blob={expected}");
}
}
renderer.drop_shader_packs();
assert!(
!renderer.pack_entity_shadows(),
"dropped pack restores fallback"
);
println!("ENTITY_SHADOW_OWNERSHIP_PASSED: 27 compiled controls and no/pending/dropped pack");
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(
Window::default_attributes()
.with_visible(false)
.with_inner_size(winit::dpi::LogicalSize::new(32., 32.)),
)
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("Shadow ownership fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
@@ -1,397 +0,0 @@
//! Original asset-free standard-pack entity fixture. Run with throwaway HORIZON_HOME,
//! HORIZON_LAUNCHER_HOME, XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md).
//! `cargo run -p horizon-client-render --example standard_pack_gpu_entities -- <output-dir>`
use client::{
gfx::{
Frame, Renderer, ScreenshotRequest,
entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex},
native_render,
pack_draw::EntitySpan,
},
graphics::GraphicsApi,
hud::HudBatch,
};
use horizon_client_render as client;
use std::{
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
static NEEDS_SPANS: AtomicBool = AtomicBool::new(false);
static OBSERVED: AtomicUsize = AtomicUsize::new(0);
struct Observer;
impl native_render::RenderHooks for Observer {
fn render_needs(&self, event: &mut native_render::RenderNeedsEvent<'_>) {
event.needs.entity_spans |= NEEDS_SPANS.load(Ordering::Relaxed);
}
fn world_drawn(&self, event: &mut native_render::WorldDrawnEvent<'_, '_>) {
if NEEDS_SPANS.load(Ordering::Relaxed) {
assert_eq!(event.world.entities.len(), 12);
assert_eq!(
event.world.spans.iter().map(|s| s.id).collect::<Vec<_>>(),
[77, 88, 99]
);
OBSERVED.fetch_add(1, Ordering::Relaxed);
}
}
}
fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
let shaders = folder.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
for program in [
"gbuffers_entities",
"gbuffers_block",
"gbuffers_entities_translucent",
] {
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
std::fs::write(
shaders.join(format!("{program}.fsh")),
if valid {
fragment
} else {
"#version 120\nvoid main(){ invalid shader; }"
},
)
.unwrap();
}
std::fs::write(shaders.join("final.vsh"), vertex).unwrap();
std::fs::write(shaders.join("final.fsh"), "#version 120\nvarying vec2 uv; uniform sampler2D colortex0; void main(){gl_FragColor=texture2D(colortex0,uv);}\n").unwrap();
std::fs::write(
shaders.join("shaders.properties"),
"oldLighting=false\nshadow.enabled=false\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(folder).unwrap();
let mut macros = horizon_shaders::Macros::standard();
renderer.define_pack_macros(&mut macros);
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &renderer.pack_inputs());
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
assert!(
!valid || notes.iter().all(|n| !n.contains("skipped")),
"{notes:?}"
);
}
fn read(path: &Path) -> Vec<u8> {
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut bytes).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
bytes.truncate(info.buffer_size());
bytes
}
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let out = std::env::args().nth(1).ok_or("output directory required")?;
let out = Path::new(&out);
std::fs::create_dir_all(out)?;
native_render::install(Box::new([&Observer])).map_err(|_| "observer already installed")?;
let white = [255; 16 * 16 * 4];
let zero = [0; 4];
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&white,
16,
16,
1,
&[],
&[],
(&white, 16, 16),
(&zero, 1, 1),
(&zero, 1, 1),
(&zero, 1),
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
None,
None,
GraphicsApi::Vulkan,
false,
true,
))?;
renderer.resize(320, 180);
assert!(
renderer.skins_entities(),
"fixture requires compute skinning"
);
let unmodified_tagged = renderer.skins_tagged();
pack(&mut renderer, &out.join("valid-pack"), true);
assert!(
!renderer.skins_tagged(),
"eye/tagged layers keep the CPU path with a pack"
);
let spans = [77, 88, 99]
.into_iter()
.enumerate()
.map(|(i, id)| EntitySpan {
start: i as u32 * 4,
stage: horizon_shaders::render_stage("ENTITIES"),
id,
hidden: i == 2,
..Default::default()
})
.collect::<Vec<_>>();
let mut skin = SkinFrame {
reset: true,
vertices: 12,
spans: spans.clone(),
parts: vec![SkinPart {
matrix: glam::Mat4::IDENTITY.to_cols_array(),
normal: glam::Mat3::IDENTITY.to_cols_array(),
}],
..Default::default()
};
for (i, x) in [-1.8, 0.3, -0.7].into_iter().enumerate() {
for (pos, uv) in [
[x, -0.8, 5.],
[x, 0.8, 5.],
[x + 1.4, 0.8, 5.],
[x + 1.4, -0.8, 5.],
]
.into_iter()
.zip([[0., 0.], [0., 1.], [1., 1.], [1., 0.]])
{
skin.new_model.push(SkinVertex {
pos,
uv,
axis: 5,
part: 0,
});
}
skin.jobs.push(SkinJob {
first: i as u32 * 4,
dst: i as u32 * 4,
count: 4,
rect: [0., 0., 1., 1.],
color: u32::MAX,
light: 0xf0f0,
overlay: if i == 0 { 1 } else { 5 },
..Default::default()
});
}
let cpu = client::gfx::entity_skin::expand(&skin.new_model, &skin);
let hud = HudBatch::new([320, 180]);
let mut frame = fixture_frame(&hud);
frame.title = false;
frame.camera = glam::DVec3::ZERO;
frame.eye = glam::DVec3::ZERO;
frame.pitch = 0.;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("cpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
let matches = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [77, 77, 0])
.count();
assert!(
matches > 100,
"original entity-ID/hurt output, got {matches} pixels"
);
let white = expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[..3] == [88, 192, 0])
.count();
assert!(
white > 100,
"original entity-ID/white-flash output, got {white} pixels"
);
assert_eq!(
expected
.as_chunks::<4>()
.0
.iter()
.filter(|p| p[0] == 99)
.count(),
0,
"hidden caster is not camera-visible"
);
assert!(
renderer.skins_entities(),
"compiled standard-pack stages must accept GPU posing"
);
frame.entities = &[];
frame.entity_spans = &[];
frame.skin = Some(&skin);
renderer.screenshot = Some(ScreenshotRequest {
path: out.join("gpu.png"),
size: None,
scaled: false,
});
renderer.render(&frame)?;
assert_eq!(
read(&out.join("gpu.png")),
expected,
"CPU and GPU standard-pack pixels"
);
NEEDS_SPANS.store(true, Ordering::Relaxed);
assert!(
!renderer.skins_entities(),
"native CPU-stream observer keeps its fallback"
);
frame.skin = None;
frame.entities = &cpu;
frame.entity_spans = &spans;
renderer.render(&frame)?;
assert_eq!(OBSERVED.load(Ordering::Relaxed), 1);
NEEDS_SPANS.store(false, Ordering::Relaxed);
assert!(renderer.skins_entities());
pack(&mut renderer, &out.join("failed-pack"), false);
assert!(
!renderer.skins_entities(),
"failed entity programs retain CPU posing"
);
renderer.drop_shader_packs();
assert!(
renderer.skins_entities(),
"ordinary path returns when the pack is dropped"
);
assert_eq!(renderer.skins_tagged(), unmodified_tagged);
std::fs::write(
out.join("result.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"cpu_gpu_pixels_equal": true, "entity_id_hurt_pixels": matches, "hidden_subject_pixels": 0,
"entity_id_white_flash_pixels": white,
"graphics_adapter": client::crash::system_details("fixture").into_iter()
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
"native_observer_frames": OBSERVED.load(Ordering::Relaxed), "player_game_started": false,
"fixture_only_not_gameplay_or_fps_proof": true
}))?,
)?;
Ok(())
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = fixture(window) {
eprintln!("fixture failed: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
for key in [
"HORIZON_HOME",
"HORIZON_LAUNCHER_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
] {
assert!(
std::env::var_os(key).is_some(),
"set throwaway {key} (AGENTS.md)"
);
}
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
weather_time: client::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
eye: glam::DVec3::new(0.5, 100.0, 0.5),
eye_rotation: [0.0, 10.0],
first_person: true,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: client::environment::Sky::default(),
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
water_plane: None,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
chunk_fade: 0.0,
post_effects: client::post_chain::Request::none(),
menu_blur: None,
}
}
+3 -16
View File
@@ -105,14 +105,7 @@ pub struct RenderAssets {
pub pbr_size: u32,
/// OptiFine connected textures (empty without `optifine/ctm` files).
pub connected: crate::connected::ConnectedTextures,
/// Fusion connected textures (empty without Fusion's connecting textures and models).
pub fusion: crate::fusion::FusionTextures,
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
/// sprites are when it changes (`native_block_sprites_version`).
pub generation: u64,
}
/// The last `RenderAssets::generation` handed out.
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -124,7 +117,8 @@ impl Assets {
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
return Err(invalid("resources do not match Horizon's pinned data version").into());
}
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
let (registry, flags) =
BlockRegistry::from_facts_json_with_flags(File::open(root.join("facts.json"))?)?;
let bundle: Value =
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
let mut definitions = TagDefinitions::default();
@@ -230,7 +224,7 @@ impl RenderAssets {
} else {
crate::model_library::ParsedModels::default()
};
let mut models = if draws {
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
@@ -250,11 +244,6 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
};
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
@@ -315,8 +304,6 @@ impl RenderAssets {
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
connected,
fusion,
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
})
}
}
@@ -180,16 +180,6 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
+4 -15
View File
@@ -183,7 +183,6 @@ struct Decode {
key: String,
file: PathBuf,
channel: ChannelId,
looping: bool,
}
/// Give a channel waiting on its sound the decoded samples and start it, as
/// the buffer future attaching and playing the source. A source stopped or
@@ -421,7 +420,7 @@ impl Backend for MixerBackend {
let key = format!("{namespace}:{path}");
let cached = self.cache.lock().ok().and_then(|mut c| c.get(&key));
if let Some(samples) = cached {
attach(&self.mixer, channel, samples, looping);
attach(&self.mixer, channel, samples, false);
return;
}
let decoder = self.decoder.get_or_insert_with(|| {
@@ -440,18 +439,13 @@ impl Backend for MixerBackend {
}
samples
});
attach(&mixer, job.channel, samples, job.looping);
attach(&mixer, job.channel, samples, false);
}
})
.expect("sound decoder thread");
send
});
let _ = decoder.send(Decode {
key,
file,
channel,
looping,
});
let _ = decoder.send(Decode { key, file, channel });
}
/// `alSourceStop`: any state becomes stopped, an initial source too (a
/// sound still decoding then never starts).
@@ -1212,13 +1206,8 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = registry;
self.engine.registry = SoundRegistry::load(root);
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+6 -74
View File
@@ -187,7 +187,6 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -254,33 +253,9 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
/// account is joining the server whose hash is `server_hash`.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -289,19 +264,11 @@ fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), Join
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
}
}
@@ -597,41 +564,6 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
+16 -37
View File
@@ -5,6 +5,7 @@ use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -170,9 +171,7 @@ fn color(v: &Value) -> Option<u32> {
}
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_core::prepared_files::read(
&path,
)?));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
@@ -278,22 +277,21 @@ impl Biomes {
dry: colormap(root, "dry_foliage")?,
};
let mut list = Vec::new();
// The folder's own files (not its subfolders'), by file name: through `json_folder`,
// from the prepared data file when there is one.
let folder = horizon_world::json_folder::read(&root.join("data/minecraft/worldgen/biome"))?;
let mut files: Vec<_> = folder
.files
.iter()
.filter(|f| !f.name.contains('/'))
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort_by_cached_key(|f| format!("{}.json", f.name));
for file in files {
let v: Value = serde_json::from_slice(file.bytes()?)?;
list.push(BiomeInfo::from_json(
format!("minecraft:{}", file.name),
&v,
&maps,
));
names.sort();
for path in names {
let name = format!(
"minecraft:{}",
path.file_stem()
.and_then(|s| s.to_str())
.ok_or("bad biome")?
);
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
list.push(BiomeInfo::from_json(name, &v, &maps));
}
let index = list
.iter()
@@ -434,25 +432,6 @@ impl Biomes {
}
changed.then_some(out)
}
/// This table (the biome files') with its colours passed through `f`, when
/// they differ from `current`'s: what a refresh of a table already
/// coloured by `f` once must not compound.
pub fn recolored_from(
&self,
current: &Self,
f: impl FnMut(&str, &mut BiomeColors),
) -> Option<Self> {
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
let same = out.list.len() == current.list.len()
&& out
.list
.iter()
.zip(&current.list)
.all(|(a, b)| tints(a) == tints(b));
(!same).then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
@@ -292,10 +292,6 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -577,7 +573,6 @@ mod tests {
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
let p = (1, 64, 2);
let mut entities = level();
@@ -383,7 +383,7 @@ impl Shulker {
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the lid box at half progress, deflated by 1e-6).
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,432 +0,0 @@
//! Where a block's textures are in the block sprite arrays, side by side, for drawing the block
//! somewhere the mesher does not: a native mod's own pass drawing blocks as voxels
//! (`native_block_sprites`, `WorldDrawn::block_sprites`; docs/native-mods.md). The answer is the
//! model's own faces, as the mesher bakes them: each face's sprite (its array and layer), where on
//! the block's side it lies and how its texture runs across it, and its tint source.
//!
//! Each sprite is a whole layer of a square texture array (`sprites::Sprites`), so a face's UVs run
//! 0 to 1 within its layer; the renderer's split (`Renderer::sprite_split`) layers to an array.
use crate::{mesher::TintSource, models::catalog::BakedFace, streaming::WorkerContext};
use horizon_assets::model::Direction;
use horizon_core::position::BlockPos;
use horizon_world::registry::{BlockRegistry, BlockStateId};
/// A sprite: its array (0 for every vanilla texture on most GPUs; past the first array's layers in
/// 1 to 7) and its layer in that array.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SpriteRef {
pub array: u32,
pub layer: u32,
/// It animates (water, lava, fire, sea lanterns...): the arrays always hold the frame being shown.
pub animated: bool,
/// Some of its pixels are clear (alpha 0): leaves, glass, flowers. Draw those as holes.
pub cutout: bool,
}
/// One face of a block's model on one of its sides.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FaceSprite {
pub sprite: SpriteRef,
/// Where the face lies on the side, in the side's two axes ([`side_axes`]), from 0 to 1 across the block:
/// `[a0, b0, a1, b1]`. `[0, 0, 1, 1]` for a whole face.
pub rect: [f32; 4],
/// How deep into the block the face lies from the side's own plane (0 for a cube's face, 0.5 for a slab's top).
pub inset: f32,
/// The texture across the side: the point (a, b) on it shows `uv = [u0, v0] + a * [du_u, du_v] + b * [dv_u,
/// dv_v]` of the sprite, `[u0, v0, du_u, du_v, dv_u, dv_v]`. Rotations and mirrors (a log on its side, a
/// model's `rotation` and `uvlock`) are in it.
pub uv: [f32; 6],
/// Its colour source, none for an untinted face: multiply the texture by the source's colour.
pub tint: Option<TintSource>,
/// It has partly clear pixels: drawn sorted and blended.
pub translucent: bool,
}
/// What a block draws on each side, in [`Direction::ALL`]'s order (down, up, north, south, west, east).
#[derive(Clone, Debug, PartialEq)]
pub struct BlockSprites {
/// The faces on each side, in the model's order: the one drawn first first (a grass block's side: its dirt
/// and grass, then the grass overlay tinted over it). A side with none is empty. Faces that do not lie flat
/// on a side (a flower's crossed planes, a rotated element) are left out.
pub sides: [Vec<FaceSprite>; 6],
/// The model's particle sprite (what breaking it scatters, a flower's whole picture), if it has one.
pub particle: Option<SpriteRef>,
/// The model is a plain cube: one whole face on each side, at its plane.
pub cube: bool,
}
/// The two axes a side's `rect` and `uv` count along, as indices into (x, y, z): down and up across x then z; north
/// and south across x then y; west and east across z then y. (The block's corner at 0, 0, 0 is 0 on each.)
pub fn side_axes(side: Direction) -> (usize, usize) {
match side {
Direction::Down | Direction::Up => (0, 2),
Direction::North | Direction::South => (0, 1),
Direction::West | Direction::East => (2, 1),
}
}
/// The axis a side faces along, and where its plane is (0 or 1).
fn side_plane(side: Direction) -> (usize, f32) {
match side {
Direction::Down => (1, 0.0),
Direction::Up => (1, 1.0),
Direction::North => (2, 0.0),
Direction::South => (2, 1.0),
Direction::West => (0, 0.0),
Direction::East => (0, 1.0),
}
}
/// A block state from `name[property=value,...]`, the namespace `minecraft:` when it has none; properties left
/// out take the block's default. None for a block or a property the game does not know.
pub fn parse_state(registry: &BlockRegistry, text: &str) -> Option<BlockStateId> {
let (name, props) = match text.trim().split_once('[') {
Some((n, rest)) => (n, rest.strip_suffix(']')?),
None => (text.trim(), ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry.default_state(&name)?;
let mut state = registry.state(default)?.clone();
for pair in props.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=')?;
let slot = state.properties.get_mut(k.trim())?;
*slot = v.trim().to_owned();
}
registry.resolve(&state).ok()
}
/// The sprites `context`'s resources hold, `split` layers to an array (`Renderer::sprite_split`).
pub struct Lookup<'a> {
context: &'a WorkerContext,
split: u32,
}
impl<'a> Lookup<'a> {
pub fn new(context: &'a WorkerContext, split: u32) -> Self {
Self {
context,
split: split.max(1),
}
}
/// The sprite at `layer` of all the layers.
pub fn sprite(&self, layer: u32) -> SpriteRef {
let render = &self.context.render;
SpriteRef {
array: layer / self.split,
layer: layer % self.split,
animated: render.animations.iter().any(|a| a.layer == layer),
cutout: render
.sprite_mips
.get(layer as usize)
.is_some_and(|m| m.has_transparent),
}
}
/// A texture by name (`minecraft:block/stone`, `block/stone`); none for one no model uses (the arrays hold only
/// the textures models and the game's own drawing name).
pub fn texture(&self, name: &str) -> Option<SpriteRef> {
let layers = &self.context.render.sprite_layers;
let layer = layers
.get(name)
.or_else(|| layers.get(&format!("minecraft:{name}")))?;
Some(self.sprite(*layer))
}
/// What block `name` (`minecraft:oak_log[axis=x]`; properties left out take the default) draws on each side;
/// none for a block the game does not know.
pub fn block(&self, name: &str) -> Option<BlockSprites> {
let state = parse_state(self.context.assets.registry, name)?;
Some(self.state(state))
}
/// What `state` draws on each side. A fluid has its still texture on all six; a block drawn only as a block
/// entity (a chest, a sign) or not at all (air) has empty sides, and its particle sprite if any.
pub fn state(&self, state: BlockStateId) -> BlockSprites {
let render = &self.context.render;
let catalog = &render.model_catalog;
let mut sides: [Vec<FaceSprite>; 6] = Default::default();
let block = self
.context
.assets
.registry
.state(state)
.map_or("", |s| s.name.as_str());
let fluid = match block {
"minecraft:water" | "minecraft:bubble_column" => {
Some(("block/water_still", Some(TintSource::Water), true))
}
"minecraft:lava" => Some(("block/lava_still", None, false)),
_ => None,
};
if let Some((texture, tint, translucent)) = fluid {
let sprite = self.texture(texture);
if let Some(sprite) = sprite {
for side in &mut sides {
side.push(FaceSprite {
sprite,
rect: [0.0, 0.0, 1.0, 1.0],
inset: 0.0,
uv: [0.0, 0.0, 1.0, 0.0, 0.0, 1.0],
tint,
translucent,
});
}
}
return BlockSprites {
sides,
particle: sprite,
cube: sprite.is_some(),
};
}
let models = catalog
.collect(state, BlockPos::new(0, 0, 0))
.unwrap_or_default();
let mut all = 0;
for face in models.iter().flat_map(|(_, m)| &m.faces) {
all += 1;
let Some((side, sprite)) = self.face(state, face) else {
continue;
};
sides[side as usize].push(sprite);
}
let particle = catalog
.particle(state)
.or_else(|| {
models
.iter()
.flat_map(|(_, m)| &m.faces)
.map(|f| f.layer)
.next()
})
.map(|layer| self.sprite(layer));
// a cube: every side has a whole face at its plane (and perhaps overlays laid on it), and the model has no
// other faces
let whole = |f: &FaceSprite| f.rect == [0.0, 0.0, 1.0, 1.0] && f.inset == 0.0;
let cube = all == sides.iter().map(Vec::len).sum::<usize>()
&& sides.iter().all(|s| !s.is_empty() && s.iter().all(whole));
BlockSprites {
sides,
particle,
cube,
}
}
/// A model face as it lies on its side; none when it does not lie flat on one.
fn face(&self, state: BlockStateId, face: &BakedFace) -> Option<(Direction, FaceSprite)> {
let q = &face.quad;
let side = q.facing;
let (normal, plane) = side_plane(side);
let depth = q.positions[0][normal];
if q.positions.iter().any(|p| (p[normal] - depth).abs() > 1e-4) {
return None;
}
let (a, b) = side_axes(side);
let ab: [[f32; 2]; 4] = q.positions.map(|p| [p[a], p[b]]);
let uv = affine(&ab, &q.uv)?;
let (mut a0, mut b0, mut a1, mut b1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for [x, y] in ab {
a0 = a0.min(x);
b0 = b0.min(y);
a1 = a1.max(x);
b1 = b1.max(y);
}
let snap = |v: f32| {
let r = (v * 4096.0).round() / 4096.0;
if r == 0.0 { 0.0 } else { r }
};
Some((
side,
FaceSprite {
sprite: self.sprite(face.layer),
rect: [a0, b0, a1, b1].map(snap),
inset: snap((depth - plane).abs()),
uv: uv.map(snap),
tint: self.context.tables.tint_source(state, q.tint_index),
translucent: face.translucent,
},
))
}
}
/// The affine map from a face's points on its side to its UVs, `[u0, v0, du_u, du_v, dv_u, dv_v]`, from three of
/// its corners that are not in a line (a model's faces are parallelograms on their side).
fn affine(ab: &[[f32; 2]; 4], uv: &[[f32; 2]; 4]) -> Option<[f32; 6]> {
for [i, j, k] in [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]] {
let d1 = [ab[j][0] - ab[i][0], ab[j][1] - ab[i][1]];
let d2 = [ab[k][0] - ab[i][0], ab[k][1] - ab[i][1]];
let det = d1[0] * d2[1] - d1[1] * d2[0];
if det.abs() < 1e-6 {
continue;
}
let e1 = [uv[j][0] - uv[i][0], uv[j][1] - uv[i][1]];
let e2 = [uv[k][0] - uv[i][0], uv[k][1] - uv[i][1]];
// e1 = d1.a * du + d1.b * dv, e2 = d2.a * du + d2.b * dv
let du = [
(e1[0] * d2[1] - e2[0] * d1[1]) / det,
(e1[1] * d2[1] - e2[1] * d1[1]) / det,
];
let dv = [
(e2[0] * d1[0] - e1[0] * d2[0]) / det,
(e2[1] * d1[0] - e1[1] * d2[0]) / det,
];
let o = [
uv[i][0] - ab[i][0] * du[0] - ab[i][1] * dv[0],
uv[i][1] - ab[i][0] * du[1] - ab[i][1] * dv[1],
];
return Some([o[0], o[1], du[0], du[1], dv[0], dv[1]]);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_faces_texture_runs_across_its_side() {
// a whole face, the texture upright: u along a, v along b
let ab = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
assert_eq!(
affine(&ab, &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]),
Some([0.0, 0.0, 1.0, 0.0, 0.0, 1.0])
);
// turned a quarter (a log on its side): u runs down b, v back along a
let turned = affine(&ab, &[[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]).unwrap();
assert_eq!(turned, [0.0, 1.0, 0.0, -1.0, 1.0, 0.0]);
// a slab's side: the lower half of the block, the lower half of the texture (v counts down)
let half = [[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]];
let m = affine(&half, &[[0.0, 1.0], [1.0, 1.0], [1.0, 0.5], [0.0, 0.5]]).unwrap();
assert_eq!(m, [0.0, 1.0, 1.0, 0.0, 0.0, -1.0]);
// a face seen edge on has no map
assert_eq!(affine(&[[0.0, 0.0]; 4], &[[0.0, 0.0]; 4]), None);
}
/// The game's own blocks from real prepared resources: a cube, a grass block's tinted top and overlaid sides, a
/// log on its side (its texture turned), leaves (cut out, foliage), water, a slab and a flower.
#[test]
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
fn real_blocks_name_their_sprites() -> crate::Result<()> {
use crate::{
assets::Assets, biome::Biomes, blocks::BlockFacts, light::LightTables,
mesher::MeshTables,
};
use std::sync::Arc;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let tables = Arc::new(MeshTables::new(
&assets,
&render,
&facts,
crate::models::load_visibility(&root, assets.registry)?,
)?);
let context = WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
tables,
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root)?),
};
let lookup = Lookup::new(&context, 2048);
let whole = [0.0, 0.0, 1.0, 1.0];
let upright = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
let stone = lookup.block("minecraft:stone").unwrap();
assert!(stone.cube);
let top = stone.sides[Direction::Up as usize][0];
assert_eq!(
(top.rect, top.inset, top.tint, top.sprite.array),
(whole, 0.0, None, 0)
);
assert_eq!(lookup.texture("minecraft:block/stone"), Some(top.sprite));
assert_eq!(lookup.texture("block/stone"), Some(top.sprite));
assert!(!top.sprite.animated && !top.sprite.cutout);
let grass = lookup.block("grass_block").unwrap();
assert!(grass.cube);
assert_eq!(
grass.sides[Direction::Up as usize][0].tint,
Some(TintSource::Grass)
);
assert_eq!(grass.sides[Direction::Down as usize][0].tint, None);
let side = &grass.sides[Direction::North as usize];
assert_eq!(side.len(), 2, "the side and its overlay");
assert_eq!(
(side[0].tint, side[1].tint),
(None, Some(TintSource::Grass))
);
assert_eq!(
lookup.texture("block/grass_block_side_overlay"),
Some(side[1].sprite)
);
// seen from outside, a side's texture runs from its top left: on the north side (looking south, east on
// the left) u falls with x and v with y; on top, u and v run with x and z (stone's first variant is turned)
let planks = lookup.block("oak_planks").unwrap();
let uv = |b: &BlockSprites, d: Direction| b.sides[d as usize][0].uv;
let north = uv(&planks, Direction::North);
assert_eq!(north, [1.0, 1.0, -1.0, 0.0, 0.0, -1.0]);
assert_eq!(uv(&planks, Direction::Up), upright);
assert_eq!(
uv(&planks, Direction::South),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
assert_eq!(
uv(&planks, Direction::Down),
[0.0, 1.0, 1.0, 0.0, 0.0, -1.0]
);
// an upright log's side runs as stone's; on its side (axis x) the texture is turned a quarter
let log = lookup.block("oak_log").unwrap();
let log_x = lookup.block("minecraft:oak_log[axis=x]").unwrap();
assert_eq!(log.sides[Direction::North as usize][0].uv, north);
let turned = log_x.sides[Direction::North as usize][0].uv;
assert_eq!(
(turned[2], turned[5]),
(0.0, 0.0),
"u runs along y, v along x: {turned:?}"
);
assert_eq!(
log_x.sides[Direction::West as usize][0].sprite,
log.sides[Direction::Up as usize][0].sprite,
"its end faces west"
);
let leaves = lookup.block("oak_leaves").unwrap();
let leaf = leaves.sides[Direction::Up as usize][0];
assert!(leaf.sprite.cutout);
assert_eq!(leaf.tint, Some(TintSource::Foliage));
assert!(matches!(
lookup.block("spruce_leaves").unwrap().sides[0][0].tint,
Some(TintSource::Fixed(0x61_9961))
));
let water = lookup.block("water").unwrap();
let w = water.sides[Direction::Up as usize][0];
assert!(w.sprite.animated && w.translucent);
assert_eq!(w.tint, Some(TintSource::Water));
let slab = lookup.block("stone_slab").unwrap();
assert!(!slab.cube);
assert_eq!(slab.sides[Direction::Up as usize][0].inset, 0.5);
assert_eq!(
slab.sides[Direction::North as usize][0].rect,
[0.0, 0.0, 1.0, 0.5]
);
let flower = lookup.block("dandelion").unwrap();
assert!(!flower.cube && flower.sides.iter().all(Vec::is_empty));
assert!(flower.particle.is_some_and(|p| p.cutout));
assert_eq!(lookup.block("minecraft:no_such_block"), None);
assert_eq!(lookup.block("stone[no_such=1]"), None);
assert!(lookup.block("air").unwrap().sides.iter().all(Vec::is_empty));
Ok(())
}
}
File diff suppressed because it is too large. Load diff
@@ -1,452 +0,0 @@
//! Finding a server's BlueMap without the player naming it
//! (`blueMapAutoDetect`, off by default): the BlueMap worker tries a few
//! likely web addresses (`candidates`), taking only one whose `settings.json`
//! is BlueMap's web-app settings, and the answer is kept per server in
//! `server-terrain/<server>/BlueMap/detected.txt` so a rejoin asks nothing.
//! A `blueMap:` line the player wrote always wins.
use crate::bluemap::Settings;
use std::{
net::IpAddr,
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, SystemTime},
};
/// The answer kept per server, beside its BlueMap tiles.
pub const FILE: &str = "detected.txt";
/// A server found without a map is asked again after this long.
pub const RETRY_NONE: Duration = Duration::from_secs(24 * 3600);
/// A map found is checked again (its address first) after this long.
pub const RETRY_FOUND: Duration = Duration::from_secs(7 * 24 * 3600);
/// Each probe gets this long, connecting included.
pub const TIMEOUT: Duration = Duration::from_secs(4);
/// Probes start this far apart at least.
pub const GAP: Duration = Duration::from_millis(500);
/// Where a level's BlueMap comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
/// The player's `blueMap:` line, or a map found before.
Url {
url: String,
maps: Vec<(String, String)>,
},
/// To be looked for at these addresses, in order.
Detect(Vec<String>),
}
/// What the server at `address` draws far terrain from, its BlueMap folder
/// being `dir`: `None` when the player named no map and either auto-detect
/// is off or the server was found without one recently.
pub fn source(settings: &Settings, address: &str, dir: &Path, now: SystemTime) -> Option<Source> {
if let Some(server) = settings.server(address) {
return Some(Source::Url {
url: server.url.clone(),
maps: server.maps.clone(),
});
}
if !settings.auto_detect {
return None;
}
let mut probes = Vec::new();
// Only an address this server's would be looked for at: a remembered
// one from elsewhere (a copied folder) names someone else's.
let ours = candidates(address);
match remembered(dir, now) {
Remembered::Found(url) | Remembered::Stale(Some(url)) if !ours.contains(&url) => {}
Remembered::Found(url) => {
return Some(Source::Url {
url,
maps: Vec::new(),
});
}
Remembered::Nothing => return None,
Remembered::Stale(Some(url)) => probes.push(url),
Remembered::Stale(None) => {}
}
for candidate in ours {
if !probes.contains(&candidate) {
probes.push(candidate);
}
}
(!probes.is_empty()).then_some(Source::Detect(probes))
}
/// The first labels a game server's host name takes beside its site's.
const GAME_LABELS: &[&str] = &["play", "mc", "minecraft", "mine", "join", "server"];
/// Domains whose names belong to many customers (dynamic DNS, server
/// hosts): `map.` of them is someone else's.
const SHARED: &[&str] = &[
"aternos.me",
"ddns.net",
"duckdns.org",
"hopto.org",
"minehut.gg",
"no-ip.org",
"playit.gg",
"serveminecraft.net",
"zapto.org",
];
/// The web addresses a server's BlueMap likely answers at: BlueMap's own
/// web server (port 8100), the server's host, and its `map.` subdomain, also
/// under the parent domain of a `play.`/`mc.` style host (`GAME_LABELS`).
pub fn candidates(address: &str) -> Vec<String> {
let address = address.trim().to_ascii_lowercase();
let host = if let Some(rest) = address.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if address.matches(':').count() > 1 {
&address[..]
} else {
address.split(':').next().unwrap_or(&address)
};
let host = host.trim_end_matches('.');
if host.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
if let Ok(ip) = host.parse::<IpAddr>() {
// Certificates are for names: plain HTTP only.
let host = match ip {
IpAddr::V6(_) => format!("[{host}]"),
IpAddr::V4(_) => host.to_owned(),
};
out.push(format!("http://{host}:8100"));
out.push(format!("http://{host}"));
return out;
}
if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Vec::new();
}
out.push(format!("http://{host}:8100"));
let labels = host.split('.').count();
let map = |host: &str| (!host.starts_with("map.")).then(|| format!("map.{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("https://{map}"));
}
out.push(format!("https://{host}"));
if labels >= 2
&& let Some(map) = map(host)
{
out.push(format!("http://{map}"));
}
out.push(format!("http://{host}"));
// `play.example.net` → `map.example.net`, only for a game server's
// usual names: the parent of `name.hosting.example` is another party's.
if labels >= 3
&& let Some((first, parent)) = host.split_once('.')
&& GAME_LABELS.contains(&first)
&& !SHARED.contains(&parent)
&& let Some(map) = map(parent)
{
out.push(format!("https://{map}"));
out.push(format!("http://{map}"));
}
let mut seen = Vec::new();
out.retain(|url| {
let new = !seen.contains(url);
seen.push(url.clone());
new
});
out
}
/// Whether `json` is a BlueMap web app's `settings.json`: a list of map ids
/// and where their data lies, not any page a web server returns.
pub fn is_web_app_settings(json: &[u8]) -> bool {
let Ok(v) = serde_json::from_slice::<serde_json::Value>(json) else {
return false;
};
let maps = v.get("maps").and_then(|m| m.as_array());
maps.is_some_and(|maps| !maps.is_empty() && maps.iter().all(|m| m.is_string()))
&& v.get("mapDataRoot").is_some_and(|r| r.is_string())
}
/// Whether a BlueMap web app answers at `base`.
pub fn probe(agent: &ureq::Agent, base: &str) -> bool {
use std::io::Read;
let Ok(response) = agent
.get(&format!("{base}/settings.json"))
.timeout(TIMEOUT)
.call()
else {
return false;
};
let mut body = Vec::new();
response
.into_reader()
.take(256 << 10)
.read_to_end(&mut body)
.is_ok()
&& is_web_app_settings(&body)
}
/// What an earlier look found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Remembered {
Found(String),
/// Nothing, recently.
Nothing,
/// Never looked, or long enough ago to look again (first at the map
/// found then, if any).
Stale(Option<String>),
}
pub fn remembered(dir: &Path, now: SystemTime) -> Remembered {
let Ok(text) = std::fs::read_to_string(dir.join(FILE)) else {
return Remembered::Stale(None);
};
let mut words = text.split_whitespace();
let (kind, at) = (words.next(), words.next().and_then(|t| t.parse().ok()));
let Some(at) = at.map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs)) else {
return Remembered::Stale(None);
};
// A time ahead of the clock (a clock set wrong, a copied file) is stale.
let Ok(age) = now.duration_since(at) else {
return Remembered::Stale(None);
};
match (kind, words.next()) {
(Some("found"), Some(url)) if age < RETRY_FOUND => Remembered::Found(url.to_owned()),
(Some("found"), Some(url)) => Remembered::Stale(Some(url.to_owned())),
(Some("none"), _) if age < RETRY_NONE => Remembered::Nothing,
_ => Remembered::Stale(None),
}
}
/// Keeps what a look found (`None`: no map).
pub fn remember(dir: &Path, found: Option<&str>, now: SystemTime) {
let secs = now
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let text = match found {
Some(url) => format!("found {secs} {url}\n"),
None => format!("none {secs}\n"),
};
let _ = std::fs::create_dir_all(dir);
let _ = std::fs::write(dir.join(FILE), text);
}
/// The servers being looked at now, by BlueMap folder: one look a server at
/// a time, however many levels open meanwhile.
static LOOKING: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// The right to look for `dir`'s server's map, until dropped.
pub struct Claim(PathBuf);
impl Claim {
/// `None` while another look at the same server runs.
pub fn take(dir: &Path) -> Option<Self> {
let mut looking = LOOKING.lock().unwrap();
if looking.iter().any(|d| d == dir) {
return None;
}
looking.push(dir.to_owned());
Some(Self(dir.to_owned()))
}
}
impl Drop for Claim {
fn drop(&mut self) {
LOOKING.lock().unwrap().retain(|d| *d != self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidates_cover_the_web_server_host_and_map_subdomains() {
assert_eq!(
candidates("Play.Example.net:25566"),
[
"http://play.example.net:8100",
"https://map.play.example.net",
"https://play.example.net",
"http://map.play.example.net",
"http://play.example.net",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("example.net"),
[
"http://example.net:8100",
"https://map.example.net",
"https://example.net",
"http://map.example.net",
"http://example.net",
]
);
// Already the map's host: no `map.map.`.
assert_eq!(
candidates("map.example.net"),
[
"http://map.example.net:8100",
"https://map.example.net",
"http://map.example.net",
]
);
assert_eq!(
candidates("192.0.2.7:25565"),
["http://192.0.2.7:8100", "http://192.0.2.7"]
);
assert_eq!(
candidates("[2001:db8::7]:25565"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("2001:db8::7"),
["http://[2001:db8::7]:8100", "http://[2001:db8::7]"]
);
assert_eq!(
candidates("localhost"),
[
"http://localhost:8100",
"https://localhost",
"http://localhost"
]
);
// A hosted name's parent is the host's, not the server's.
assert!(
!candidates("play.ddns.net")
.iter()
.any(|url| url.ends_with("//map.ddns.net"))
);
assert!(
!candidates("name.host.example")
.iter()
.any(|url| url.ends_with("//map.host.example"))
);
assert!(candidates("").is_empty());
assert!(candidates("evil.example.net/path?x").is_empty());
}
#[test]
fn only_blue_maps_settings_count() {
let web_app = br#"{"version":"5.4","useCookies":true,"maps":["world","world_nether"],
"mapDataRoot":"maps","liveDataRoot":"maps","scripts":[],"styles":[]}"#;
assert!(is_web_app_settings(web_app));
assert!(!is_web_app_settings(b"<!doctype html><html></html>"));
assert!(
!is_web_app_settings(br#"{"maps":["world"]}"#),
"no data root"
);
assert!(!is_web_app_settings(br#"{"maps":[],"mapDataRoot":"maps"}"#));
assert!(!is_web_app_settings(
br#"{"maps":[1],"mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"{"maps":"world","mapDataRoot":"maps"}"#
));
assert!(!is_web_app_settings(
br#"[{"maps":["world"],"mapDataRoot":"maps"}]"#
));
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-bluemap-detect-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn answers_are_kept_and_looked_at_again_later() {
let dir = scratch("remember");
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000);
let hour = Duration::from_secs(3600);
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
remember(&dir, None, t0);
assert_eq!(remembered(&dir, t0 + hour), Remembered::Nothing);
// Written ahead of the clock: not trusted.
assert_eq!(remembered(&dir, t0 - hour), Remembered::Stale(None));
assert_eq!(remembered(&dir, t0 + RETRY_NONE), Remembered::Stale(None));
remember(&dir, Some("https://map.example.net"), t0);
assert_eq!(
remembered(&dir, t0 + 2 * RETRY_NONE),
Remembered::Found("https://map.example.net".into())
);
assert_eq!(
remembered(&dir, t0 + RETRY_FOUND),
Remembered::Stale(Some("https://map.example.net".into()))
);
std::fs::write(dir.join(FILE), "garbage").unwrap();
assert_eq!(remembered(&dir, t0), Remembered::Stale(None));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_players_map_wins_and_detection_waits_for_the_setting() {
let dir = scratch("source");
let now = SystemTime::now();
let mut settings = Settings::default();
let address = "play.example.net";
assert_eq!(
source(&settings, address, &dir, now),
None,
"off by default"
);
settings.auto_detect = true;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for");
};
assert_eq!(probes, candidates(address));
remember(&dir, None, now);
assert_eq!(
source(&settings, address, &dir, now),
None,
"none, recently"
);
remember(&dir, Some("https://map.example.net"), now);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "https://map.example.net".into(),
maps: Vec::new()
})
);
// A remembered map this server's addresses don't include (a copied
// folder's) is no map.
remember(&dir, Some("https://tracker.example"), now);
let Some(Source::Detect(probes)) = source(&settings, address, &dir, now) else {
panic!("looked for, the remembered one ignored");
};
assert_eq!(probes, candidates(address));
remember(&dir, Some("https://map.example.net"), now);
// Past a week the found map is tried first, then the rest.
let later = now + RETRY_FOUND;
let Some(Source::Detect(probes)) = source(&settings, address, &dir, later) else {
panic!("looked for again");
};
assert_eq!(probes[0], "https://map.example.net");
assert_eq!(probes.len(), candidates(address).len());
settings.servers.push(
Settings::parse_server("play.example.net http://example.net:8123 nether=n").unwrap(),
);
assert_eq!(
source(&settings, address, &dir, now),
Some(Source::Url {
url: "http://example.net:8123".into(),
maps: vec![("minecraft:the_nether".into(), "n".into())]
})
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn one_look_a_server_at_a_time() {
let dir = scratch("claim");
let claim = Claim::take(&dir).unwrap();
assert!(Claim::take(&dir).is_none());
drop(claim);
assert!(Claim::take(&dir).is_some());
}
}
@@ -118,7 +118,6 @@ mod tests {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
positions: [
[0.0, 0.5, 0.0],
@@ -136,11 +135,6 @@ mod tests {
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
}],
}
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// A server's own chat: a guest's line is a player message, so the
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
+26 -242
View File
@@ -14,14 +14,12 @@ pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
/// A level's build range, skylight and ceiling, from its dimension type
/// (`min_y`, `height`, `logical_height`, `has_skylight`, `has_ceiling`), as
/// `LevelHeightAccessor` and `SerializableChunkData.write` read them.
/// A level's build range and skylight, from its dimension type (`min_y`,
/// `height`, `logical_height`, `has_skylight`), as `LevelHeightAccessor` and
/// `SerializableChunkData.write` read them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
/// `DimensionType.hasCeiling`: weather, spawning and maps read it.
pub has_ceiling: bool,
pub min_y: i32,
pub height: i32,
/// `DimensionType.logicalHeight`: how high portals and chorus fruit reach.
@@ -30,21 +28,18 @@ pub struct LevelShape {
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: MIN_Y,
height: HEIGHT,
logical_height: HEIGHT,
};
pub const NETHER: Self = Self {
sky_light: false,
has_ceiling: true,
min_y: 0,
height: 256,
logical_height: 128,
};
pub const END: Self = Self {
sky_light: true,
has_ceiling: false,
min_y: 0,
height: 256,
logical_height: 256,
@@ -55,7 +50,6 @@ impl LevelShape {
height: i32,
logical_height: i32,
sky_light: bool,
has_ceiling: bool,
) -> Result<Self, String> {
ColumnLayout::new(min_y, height)?;
if !(0..=height).contains(&logical_height) {
@@ -65,7 +59,6 @@ impl LevelShape {
}
Ok(Self {
sky_light,
has_ceiling,
min_y,
height,
logical_height,
@@ -292,122 +285,20 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
/// Packed light per block: sky light in the high nibble, block light in the low one.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -456,15 +347,14 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -476,47 +366,13 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -708,61 +564,19 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
}
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
if s >= self.sections.len() as i32 {
return 0xF0;
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -860,33 +674,6 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
(|i| 0xA0 | (i / 256) as u8, "block"),
(|i| (i % 251) as u8, "full"),
(|_| 0x7C, "uniform"),
];
for (value, name) in cases {
let light = Light::packed(Box::new(std::array::from_fn(value)));
let form = match light {
Light::Sky(..) => "sky",
Light::Block(..) => "block",
Light::Full(_) => "full",
Light::Uniform(_) => "uniform",
};
assert_eq!(form, name);
for at in [0, 17, 1000, 4078] {
let mut run = [0u8; 18];
light.copy_run(at, &mut run);
let expected: Vec<u8> = (at..at + 18).map(value).collect();
assert_eq!(run.as_slice(), expected, "{name} at {at}");
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
}
}
}
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
@@ -1026,15 +813,12 @@ mod tests {
assert_eq!(tall.section_index(2032), None);
assert_eq!(tall.section_y(130), 2016);
assert_eq!(
LevelShape::new(-64, 384, 384, true, false),
LevelShape::new(-64, 384, 384, true),
Ok(LevelShape::OVERWORLD)
);
assert_eq!(
LevelShape::new(0, 256, 128, false, true),
Ok(LevelShape::NETHER)
);
assert!(LevelShape::new(0, 256, 257, true, false).is_err());
assert!(LevelShape::new(-60, 384, 384, true, false).is_err());
assert_eq!(LevelShape::new(0, 256, 128, false), Ok(LevelShape::NETHER));
assert!(LevelShape::new(0, 256, 257, true).is_err());
assert!(LevelShape::new(-60, 384, 384, true).is_err());
}
#[test]
@@ -1042,9 +826,9 @@ mod tests {
for shape in [LevelShape::OVERWORLD, LevelShape::NETHER, LevelShape::END] {
assert_eq!(shape.column(), ColumnLayout::VANILLA, "{shape:?}");
}
let tall = LevelShape::new(-64, 2096, 2096, true, false).unwrap();
let tall = LevelShape::new(-64, 2096, 2096, true).unwrap();
assert_eq!(tall.column(), ColumnLayout::new(-64, 2096).unwrap());
let deep = LevelShape::new(-512, 256, 256, false, true).unwrap();
let deep = LevelShape::new(-512, 256, 256, false).unwrap();
assert_eq!(deep.column(), ColumnLayout::new(-512, 832).unwrap());
assert!(LevelShape::NETHER.has_section(15) && !LevelShape::NETHER.has_section(16));
assert!(!LevelShape::NETHER.has_section(-1));
+81 -199
View File
@@ -62,65 +62,18 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -154,16 +107,10 @@ pub(crate) struct Uniform {
colors: [[f32; 4]; 4],
}
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = drift(self.time, self.partial_tick, *w);
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
@@ -203,121 +150,9 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -326,39 +161,80 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let bottom = uniform.offset[1];
let uv = input.uv;
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
emit_flat_cell(&mut self.vertices, x0, z0, uv);
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx,
dz,
dx as i32,
dz as i32,
bottom,
[
!filled(cx, cz - 1),
@@ -367,9 +243,15 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
emit_faces(&mut self.vertices, mask, x0, z0, uv);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
}
self.key = Some(key);
(true, uniform)
}
+8 -219
View File
@@ -182,24 +182,12 @@ where
movement: Vec3,
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = CollisionContext::position(bounds.min_position().y);
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// [`Self::resolve`] with the entity's captured collision context.
pub fn resolve_with_context(
&mut self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
context: CollisionContext<'_>,
) -> Result<Vec3> {
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement,
max_step,
@@ -223,25 +211,6 @@ impl SceneCollisions<'_, '_> {
) -> Result<Vec3> {
self.resolve_with_context(bounds, movement, max_step, on_ground, self.context(bounds))
}
/// [`Self::resolve`], also stopped by the world border's walls when one is
/// given with the moving entity's x and z: `Entity.collectColliders` adds
/// `WorldBorder.getCollisionShape` for any entity inside and close to it.
pub fn resolve_in_border(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
}
/// Resolve movement with the entity's captured collision context. Most
/// callers only need the position context; entities such as falling blocks
/// also affect contextual block shapes (for example powder snow).
@@ -327,88 +296,6 @@ where
)
.unwrap_or(false)
}
impl SceneCollisions<'_, '_> {
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
/// building one: no block that can collide in its inner cells, and no block that
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
/// the scan decides). The same cells, blocks and section flags the scan reads.
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
let (min, max) = (bounds.min_position(), bounds.max_position());
if !min.is_finite() || !max.is_finite() {
return false;
}
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
.offset(-1, -1, -1);
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
let mut section: Option<((i32, i32), i32, bool)> = None;
for z in lo.z..=hi.z {
for y in lo.y..=hi.y {
for x in lo.x..=hi.x {
let outer = [
x == lo.x || x == hi.x,
y == lo.y || y == hi.y,
z == lo.z || z == hi.z,
];
let kind = outer.into_iter().filter(|&o| o).count();
if kind == 3 {
continue;
}
let at = (x >> 4, z >> 4);
let chunk = match column {
Some((c, chunk)) if c == at => chunk,
_ => {
let chunk = self.world.chunks.get(&at).map(|c| &**c);
column = Some((at, chunk));
chunk
}
};
// The scan passes the cells of a column that is not resident.
let Some(chunk) = chunk else {
continue;
};
if kind != 0 {
let reaches = match section {
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
_ => {
let reaches = self.section_reaches_out(chunk, y >> 4);
section = Some((at, y >> 4, reaches));
reaches
}
};
if reaches {
return false;
}
continue;
}
if self.binding.may_collide(chunk.block(x, y, z)) {
return false;
}
}
}
}
true
}
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
/// its cell; a global palette, or a section outside the column, may.
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
})
}
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
@@ -422,7 +309,13 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
Ok(self.section_reaches_out(chunk, section_y))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
}
fn is_suffocating(
&mut self,
@@ -724,25 +617,6 @@ mod border_movement_tests {
);
}
/// A minecart (0.98 wide, 0.7 tall) rolling east at its top speed of 0.4 a tick, 0.11
/// blocks short of the wall: the wall takes all but the 0.11 (`move_cart` hands the
/// cart's x and z to `with_border`, as every entity does in `Entity.move`).
#[test]
fn a_minecart_sized_body_stops_at_the_border_wall() {
// Centre x = 4.4, half width 0.49: the cart's east face is at 4.89, the wall at 5.0.
let bounds = Aabb::new(Vec3::new(3.91, 0.0, -0.49), Vec3::new(4.89, 0.7, 0.49));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.4, 0.0]);
let moved = collisions
.resolve(bounds, Vec3::new(0.4, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.11).abs() < 1.0e-9,
"the cart was not stopped by the border: {moved:?}"
);
assert_eq!(moved.z, 0.0);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
@@ -895,88 +769,3 @@ mod shulker_collision_tests {
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
}
}
#[cfg(test)]
mod section_tests {
use super::*;
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
/// reaching into the cell above) and the scene's stone: wherever the cells say no
/// block can collide, the full resolution returns the movement, to the bit; the rest
/// is left to it. Needs the prepared resources.
#[test]
fn cells_without_collision_leave_the_movement_as_resolving_does() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("cached-collision.nbt").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let state = |name: &str| assets.registry.default_state(name).unwrap();
// Torches, and a fence at the bottom of a section above air.
for x in 17..30 {
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
}
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
let scene = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
assert!(scene.cells_without_collision(torches));
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
assert!(scene.cells_without_collision(over_floor));
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let (mut skipped, mut resolved) = (0, 0);
for i in 0..4000 {
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
let d = Vec3::new(
(next() - 0.5) * reach,
(next() - 0.5) * reach,
(next() - 0.5) * reach,
);
let fast = scene.cells_without_collision(bb.expand_towards(d));
let mut full = SceneCollisions {
world: &world,
binding: &binding,
subject: None,
};
let moved = full.resolve(bb, d, 0.0, false).unwrap();
if fast {
skipped += 1;
assert_eq!(
moved.components().map(f64::to_bits),
d.components().map(f64::to_bits),
"{bb:?} by {d:?}"
);
} else {
resolved += 1;
}
}
assert!(
skipped > 500 && resolved > 500,
"{skipped} skipped, {resolved} resolved"
);
}
}
@@ -39,8 +39,6 @@ pub enum Pick {
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
/// bottom-right).
Quarters([TileLayer; 4]),
/// Nothing but its overlays: a Fusion face whose tile is empty, or of the overlay layout.
Nothing,
}
/// A tile drawn over a face, after its own (`overlay*` methods).
+9 -78
View File
@@ -257,17 +257,6 @@ impl SkullSkin {
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
/// `*_skull` and `*_head` but the piston's head.
/// The item slots of the container `name`'s block carries, if any.
fn container_size(name: &str) -> Option<usize> {
match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
n if n.ends_with("copper_chest") => Some(27),
"minecraft:hopper" => Some(HOPPER_SLOTS),
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
n if n.ends_with("shulker_box") => Some(27),
_ => None,
}
}
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
@@ -400,9 +389,6 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:jukebox" {
return Some(Self::Jukebox(Jukebox::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
@@ -433,56 +419,15 @@ impl BlockEntity {
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
Some(Self::Chest(vec![None; container_size(name)?]))
}
/// `BlockEntity.isValidBlockState`: whether this kind of entity belongs
/// under `name`'s block — vanilla keeps a set block's existing entity
/// only when its type is in the block's set, and drops it for the
/// block's own otherwise. Each kind fits the blocks `for_block` maps to
/// it (a furnace is its kind's, a sign only its standing or hanging
/// kind); a mod block's own can't tell its block here, so a non-vanilla
/// name is trusted — a name change drops it either way.
pub fn fits(&self, name: &str) -> bool {
match self {
Self::Chest(items) => container_size(name) == Some(items.len()),
Self::Furnace(f) => FurnaceKind::from_block(name) == Some(f.kind),
Self::Sign(s) => crate::signs::sign_kind(name) == Some(s.hanging),
Self::Sculk(s) => {
crate::sculk::SculkEntity::for_block(name).is_some_and(|n| n.id() == s.id())
}
Self::CommandBlock(_) => crate::command_block::Mode::of_block(name).is_some(),
Self::Skull(_) => is_skull(name),
Self::Banner(_) => name.ends_with("_banner"),
Self::Shelf(_) => name == "minecraft:shelf" || name.ends_with("_shelf"),
Self::Mod(_) => !name.starts_with("minecraft:"),
Self::Beehive(_) => matches!(name, "minecraft:beehive" | "minecraft:bee_nest"),
Self::Campfire(_) => {
matches!(name, "minecraft:campfire" | "minecraft:soul_campfire")
}
Self::Brushable(_) => matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
),
Self::Brewing(_) => name == "minecraft:brewing_stand",
Self::Beacon(_) => name == "minecraft:beacon",
Self::Jukebox(_) => name == "minecraft:jukebox",
Self::Lectern(_) => name == "minecraft:lectern",
Self::CreakingHeart(_) => name == "minecraft:creaking_heart",
Self::Spawner(_) => name == "minecraft:spawner",
Self::TrialSpawner(_) => name == "minecraft:trial_spawner",
Self::Vault(_) => name == "minecraft:vault",
Self::Crafter(_) => name == "minecraft:crafter",
Self::EndGateway(_) => name == "minecraft:end_gateway",
Self::DaylightDetector => name == "minecraft:daylight_detector",
Self::StructureBlock(_) => name == "minecraft:structure_block",
Self::TestBlock(_) => name == "minecraft:test_block",
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
Self::JigsawBlock(_) => name == "minecraft:jigsaw",
Self::PotentSulfur(_) => name == "minecraft:potent_sulfur",
Self::ChiseledBookshelf(_) => name == "minecraft:chiseled_bookshelf",
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
"minecraft:hopper" => HOPPER_SLOTS,
"minecraft:dispenser" | "minecraft:dropper" => 9,
n if n.ends_with("shulker_box") => 27,
_ => return None,
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &str {
match self {
@@ -784,20 +729,6 @@ mod tests {
assert_eq!(entity.contents().len(), 1);
}
/// A placed jukebox gets its block entity (`JukeboxBlock.newBlockEntity`),
/// so the item's block entity data lands somewhere and the box can hold a
/// disc without a player putting one in.
#[test]
fn a_placed_jukebox_starts_as_an_empty_jukebox_entity() {
let entity = BlockEntity::for_block("minecraft:jukebox").expect("a jukebox");
assert_eq!(entity.id(), "minecraft:jukebox");
assert!(matches!(entity, BlockEntity::Jukebox(_)));
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none());
// Another block's name is not a jukebox.
assert!(BlockEntity::for_block("minecraft:stone").is_none());
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+7 -95
View File
@@ -20,53 +20,6 @@ thread_local! {
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
/// The native mod hook this thread is inside, for the panic hook to name.
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
}
/// A call into one hook of a native mod.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeModCall {
/// The mod's `[mod] id`, None in a build that didn't name its mods.
pub id: Option<&'static str>,
/// Its place in dispatch order, which names it then.
pub index: usize,
pub hook: &'static str,
}
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
/// unwinds. A hook called from a hook is named by the inner call.
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
NativeModGuard {
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
}
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
outer: Option<NativeModCall>,
}
impl Drop for NativeModGuard {
fn drop(&mut self) {
IN_NATIVE_MOD.with(|c| c.set(self.outer));
}
}
/// `message`, with the native mod hook this thread is inside, if any.
fn attributed(message: String) -> String {
match IN_NATIVE_MOD.with(Cell::get) {
Some(NativeModCall {
id: Some(id), hook, ..
}) => format!("{message}\nin native mod {id}, hook {hook}"),
Some(NativeModCall {
id: None,
index,
hook,
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
None => message,
}
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
@@ -465,10 +418,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
attributed(match info.location() {
match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
})
}
}
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
@@ -565,8 +518,7 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
@@ -584,8 +536,7 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
/// panic inside a native mod's hook names the mod and the hook.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -724,25 +675,6 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_native_mods_hook_names_it() {
let outer = enter_native_mod(NativeModCall {
id: None,
index: 1,
hook: "render",
});
let inner = enter_native_mod(NativeModCall {
id: Some("b"),
index: 0,
hook: "tick",
});
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
drop(inner);
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
drop(outer);
assert_eq!(attributed("boom".into()), "boom");
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -854,37 +786,17 @@ mod tests {
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
let caught = std::sync::Arc::new(Mutex::new(None));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if panic_message(info).contains("the test crash") {
sink.lock().unwrap().push(panic_report(info, "client"));
*sink.lock().unwrap() = Some(panic_report(info, "client"));
}
}));
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
// The same panic inside a native mod's hook, which the hook sees before the unwind.
let _ = std::panic::catch_unwind(|| {
let _guard = enter_native_mod(NativeModCall {
id: Some("a"),
index: 0,
hook: "tick",
});
panic!("the test crash")
});
std::panic::set_hook(previous);
// A caught panic leaves the thread as it found it.
assert_eq!(attributed("boom".into()), "boom");
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
assert_eq!(reports.len(), 2, "the hook ran for both panics");
let in_mod = reports.pop().unwrap();
assert!(
in_mod.error.ends_with("\nin native mod a, hook tick"),
"{}",
in_mod.error
);
let report = reports.pop().unwrap();
assert!(!report.error.contains("native mod"));
let report = caught.lock().unwrap().take().expect("the hook ran");
assert!(report.error.contains("the test crash"));
assert!(report.error.contains("crash.rs"));
// The first frame is the test's own code, not the panic machinery.
+1 -5
View File
@@ -121,11 +121,7 @@ impl DataPack {
}
/// The packs in `<world>/datapacks`, sorted by name.
pub fn discover(world: &Path) -> Vec<DataPack> {
discover_folder(&world.join("datapacks"))
}
/// Packs in Create World's temporary pack folder, before a world exists.
pub fn discover_folder(dir: &Path) -> Vec<DataPack> {
let Ok(entries) = std::fs::read_dir(dir) else {
let Ok(entries) = std::fs::read_dir(world.join("datapacks")) else {
return Vec::new();
};
let mut packs = Vec::new();
+5 -7
View File
@@ -421,33 +421,31 @@ impl StatusFlags {
/// The attribute modifier an effect adds at amplifier 0
/// (`MobEffect.addAttributeModifier`): (attribute, id, amount, operation).
/// Each level multiplies the amount (`amplifier + 1`). Vanilla's amounts are
/// `float` literals widened to the modifier's `double`, so `f64::from` the
/// `f32` to keep the low digits.
/// Each level multiplies the amount (`amplifier + 1`).
pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64, &'static str)> {
match effect {
"speed" => Some((
"minecraft:movement_speed",
"minecraft:effect.speed",
f64::from(0.2f32),
0.2,
"add_multiplied_total",
)),
"slowness" => Some((
"minecraft:movement_speed",
"minecraft:effect.slowness",
f64::from(-0.15f32),
-0.15,
"add_multiplied_total",
)),
"haste" => Some((
"minecraft:attack_speed",
"minecraft:effect.haste",
f64::from(0.1f32),
0.1,
"add_multiplied_total",
)),
"mining_fatigue" => Some((
"minecraft:attack_speed",
"minecraft:effect.mining_fatigue",
f64::from(-0.1f32),
-0.1,
"add_multiplied_total",
)),
"strength" => Some((
+2 -13
View File
@@ -147,8 +147,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
@@ -352,9 +351,7 @@ fn shadow_color(in: Out) -> vec4<f32> {
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -376,14 +373,6 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
+10 -228
View File
@@ -60,10 +60,6 @@ impl Corner {
#[derive(Clone, Debug, PartialEq)]
pub struct Extras {
/// The Select Resource Packs screen's Add button, which browses
/// Modrinth. Off by default: it is Horizon's own and the screen is
/// otherwise vanilla's (tools/test_pack_screen_parity.py).
pub pack_browser: bool,
pub fullbright: bool,
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
pub dynamic_lights: bool,
@@ -124,44 +120,14 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
/// on by default as vanilla); off, the wheel only scrolls the list.
pub wheel_cycles_settings: bool,
/// The built-in music tracks the Music page turned off (sound ids): they
/// leave every pool they play in (`musicOff`, empty by default: vanilla's
/// random selection).
pub music_off: Vec<String>,
/// The Music page: the Music... button on Music & Sounds, playing a
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (all by default), and their auras and
/// trails (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_auras: bool,
}
impl Default for Extras {
fn default() -> Self {
Self {
pack_browser: false,
fullbright: false,
dynamic_lights: false,
zoom: false,
@@ -194,18 +160,8 @@ impl Default for Extras {
chat_indicator: true,
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_auras: false,
}
}
}
@@ -231,7 +187,6 @@ impl Extras {
_ => d,
};
match key.trim() {
"packBrowser" => e.pack_browser = flag(e.pack_browser),
"fullbright" => e.fullbright = flag(e.fullbright),
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
"zoom" => e.zoom = flag(e.zoom),
@@ -285,28 +240,8 @@ impl Extras {
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
"musicOff" => {
e.music_off = value
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
_ => {}
}
}
@@ -314,8 +249,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n",
bool_text(self.pack_browser),
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -353,18 +287,8 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
bool_text(self.cosmetics_auras),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -478,10 +402,6 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -492,8 +412,6 @@ pub enum Item {
HideBlockParticles,
AutoReconnect,
UiSoundReserve,
/// The Music... page on Music & Sounds (`Music page`).
MusicPage,
MouseTweaks,
MouseTweaksWheel,
Sort,
@@ -507,22 +425,13 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsAuras,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
let mut rows = vec![
vec![
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
@@ -542,20 +451,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
Ok(MusicPage),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsOthers),
Ok(CosmeticsAuras),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -567,23 +463,16 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MusicPage => "horizon.extras.music_page",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -595,23 +484,16 @@ impl Item {
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MusicPage => "horizon.extras.music_page.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -632,7 +514,6 @@ impl Item {
Item::HideBlockParticles => "Hide block-break particles",
Item::AutoReconnect => "Auto-reconnect",
Item::UiSoundReserve => "Keep channels for UI sounds",
Item::MusicPage => "Music page",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
Item::Sort => "Sort buttons",
@@ -641,14 +522,8 @@ impl Item {
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsAuras => "Others' auras and trails",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -660,9 +535,8 @@ impl Item {
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsAuras => {
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
})
@@ -686,9 +560,6 @@ impl Item {
Item::UiSoundReserve => {
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
}
Item::MusicPage => {
"Music & Sounds gets a Music... page: play any built-in track now, or turn one off so the game stops picking it. Nothing changes until you turn a track off."
}
Item::MouseTweaks => {
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
}
@@ -717,22 +588,6 @@ impl Item {
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
}
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
Item::PackBrowser => {
"Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -741,7 +596,6 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => Some(Cost::None),
Item::MusicPage => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::ChatAnimation => Some(Cost::None),
Item::OwnNameTag
@@ -749,10 +603,7 @@ impl Item {
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers | Item::CosmeticsAuras => Some(Cost::Low),
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -765,7 +616,6 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve,
Item::MusicPage => e.music_page,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
@@ -773,12 +623,8 @@ impl Item {
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::CosmeticsAuras => e.cosmetics_auras,
_ => return None,
})
}
@@ -786,31 +632,15 @@ impl Item {
pub fn value(self, e: &Extras) -> String {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.cosmetics_others {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
/// A click (`_back` for the right button: every item left is a switch,
/// so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, _back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
@@ -818,7 +648,6 @@ impl Item {
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
Item::MusicPage => e.music_page = !e.music_page,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
Item::Sort => e.sort = !e.sort,
@@ -826,17 +655,8 @@ impl Item {
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
let o = e.cosmetics_others;
e.cosmetics_others = if back { o.next().next() } else { o.next() };
}
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
Item::SortKey => {}
}
}
@@ -853,34 +673,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
assert_eq!(
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::NameTags);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Others::Hidden);
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Others::NameTags);
assert_eq!(Extras::parse(&e.serialize()), e);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Others::NameTags
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Others::Hidden
);
}
#[test]
fn everything_is_off_by_default_and_round_trips() {
let d = Extras::default();
@@ -888,8 +680,7 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && d.wheel_cycles_settings);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -930,15 +721,6 @@ mod tests {
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
e.music_off = vec![
"minecraft:music/game/calm1".into(),
"minecraft:music.menu".into(),
];
assert!(!e.music_page);
Item::MusicPage.cycle(&mut e, false);
assert_eq!(Item::MusicPage.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+15 -71
View File
@@ -258,19 +258,6 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
})
.map_or(0, |x| x + 1)
}
/// `FontSet.hasFishyAdvance`: a provider glyph whose advance is under 0 or
/// over 32 is not used. `FontSet.computeGlyphInfo` moves on to the next
/// provider that has the character, so a pack's absurd advance cannot widen
/// a line; Horizon keeps one advance per glyph and tests it here.
fn fishy_advance(advance: f32) -> bool {
!(0.0..=32.0).contains(&advance)
}
/// A Unihex glyph's advance (`UnihexProvider.Glyph.advance`): half its width
/// plus one, the width being the bits from its left to its right column.
fn hex_advance(h: &HexGlyph) -> f32 {
let width = h.right.saturating_sub(h.left) + 1;
(width / 2 + 1) as f32
}
/// `BitmapProvider.Definition.load`: each character's glyph, from the atlas
/// when the sheet is in it (`pixels` its RGBA), else from the sheet's own
/// pixels, copied into the atlas when first drawn.
@@ -752,9 +739,7 @@ impl Font {
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
if let Some(a) = advances.get(&c) {
return Some(Glyph {
rect: None,
left: 0.0,
@@ -768,10 +753,8 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
return Some(*g);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
Some(BitmapGlyph::Atlas(g)) => return Some(*g),
Some(BitmapGlyph::Sheet(cell)) => {
let (stride, pixels) = (&cell.sheet.0, &cell.sheet.1);
let mut rgba = Vec::with_capacity((cell.w * cell.h * 4) as usize);
for y in cell.y..cell.y + cell.h {
@@ -785,21 +768,17 @@ impl Font {
..cell.glyph
});
}
_ => {}
None => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !fishy_advance(hex_advance(&h))
{
if let Some(h) = glyphs.get(&u32::from(c)).copied() {
return self.rasterize_hex(region, h);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
if let Some(id) = ttf.ids.get(&c) {
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
Some((w, h, rgba)) => {
@@ -834,7 +813,7 @@ impl Font {
top: 0.0,
right: width as f32 / 2.0,
bottom: 8.0,
advance: hex_advance(&h),
advance: (width / 2 + 1) as f32,
offset: 0.5,
})
}
@@ -908,35 +887,26 @@ impl Font {
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
if let Some(a) = advances.get(&c) {
return Some(*a);
}
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
return Some(g.advance);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
return Some(cell.glyph.advance);
}
_ => {}
Some(BitmapGlyph::Atlas(g)) => return Some(g.advance),
Some(BitmapGlyph::Sheet(cell)) => return Some(cell.glyph.advance),
None => {}
}
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c))
&& !fishy_advance(hex_advance(h))
{
return Some(hex_advance(h));
if let Some(h) = glyphs.get(&u32::from(c)) {
let width = h.right.saturating_sub(h.left) + 1;
return Some((width / 2 + 1) as f32);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
if let Some(id) = ttf.ids.get(&c) {
return Some(ttf.advance(*id));
}
}
@@ -1406,32 +1376,6 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// `FontSet.computeGlyphInfo`: a provider glyph with an advance outside
/// 0..32 is skipped, so the next provider that has the character is used.
#[test]
fn a_fishy_advance_falls_through_to_the_next_provider() {
// 64 opaque columns: the first provider's 'a' would advance 65, past
// the 32 vanilla allows, so the narrow provider's 'a' is the one used.
let wide = png(64, 8, |_, _| true);
let narrow = png(16, 8, |x, _| x < 2);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/wide.png","ascent":7,"chars":["a"]},{"type":"bitmap","file":"minecraft:font/narrow.png","ascent":7,"chars":["a"]}]}"#;
let root = root(
"fishy",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/wide.png", &wide),
("assets/minecraft/textures/font/narrow.png", &narrow),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "the narrow provider wins");
assert_eq!(font.advance('a', &Style::default()), 3.0);
// Obfuscated text picks a glyph by its own advance: nothing is 65 wide.
let obfuscated = font.random_glyph(&Style::default(), 65.0);
assert_eq!(obfuscated.advance, 6.0, "the missing glyph");
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
/// part only; the next part is still measured and can break.
#[test]
-687
View File
@@ -1,687 +0,0 @@
//! Fusion connected textures at mesh time. A pack's connecting textures (a `fusion` section in
//! their `.mcmeta`) are cut into a sprite per tile at load, and every baked face of a
//! `fusion:connecting` model that shows one is given the rule it joins by: the sheet and its
//! predicates, with their blocks resolved to state ids. Meshing such a face evaluates them in
//! the face's eight directions and draws the layout's tiles through [`crate::connected`]'s
//! [`Pick`] and [`Overlay`]. Without Fusion files nothing is loaded and nothing changes.
//!
//! Directions follow a cube's default UVs ([`Axes::cube`]): the top and bottom of a model
//! turned about x, or turned with `uvlock`, join a quarter turn off.
//!
//! The format is `horizon_optifine::fusion`'s.
use crate::connected::{Overlay, Pick, TileLayer};
use crate::models::StateModels;
use crate::sprites::Sprites;
use horizon_optifine::cem::json::Json;
use horizon_optifine::ctm::connect::{self, Axes, Face};
use horizon_optifine::fusion::{
Connection, Layout, Tiles,
model::{Connections, ModelType},
predicate::Query,
texture::{Kind, Meta},
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
layout: Layout,
tiles: Vec<Option<TileLayer>>,
}
/// A sheet and what it joins by, blocks as sorted state ids.
struct Rule {
sheet: usize,
connection: Connection<Vec<u32>>,
}
#[derive(Default)]
pub struct FusionTextures {
sheets: Vec<Sheet>,
/// By [`crate::models::BakedFace::fusion`].
rules: Vec<Rule>,
/// Each state's block, for `is_same_block` (`u32::MAX` for an id the registry skips).
blocks: Vec<u32>,
}
/// A model's reference to a texture or another model (`block/glass`), namespaced.
fn namespaced(reference: &str) -> String {
horizon_optifine::matchers::namespaced(reference)
}
fn read_model(root: &Path, name: &str) -> Option<Json> {
let (ns, path) = name.split_once(':')?;
let text = horizon_core::prepared_files::read_to_string(
&root.join(format!("assets/{ns}/models/{path}.json")),
)
.ok()?;
Json::parse(&text).ok()
}
/// What a model's face draws and joins by: its sprite, its side in the model, and its
/// predicates (`None`: the texture's own).
type ModelFace = (String, Face, Option<Connection>);
/// Of a `fusion:connecting` model: each face's [`ModelFace`]. Empty for any other model.
fn connecting_model(root: &Path, name: &str) -> Vec<ModelFace> {
let Some(model) = read_model(root, name) else {
return Vec::new();
};
if ModelType::of(&model) != Some(ModelType::Connecting) {
return Vec::new();
}
// Its parents, up to one without a file (`builtin/generated`); deeper than vanilla's few
// levels is a loop.
let mut chain = vec![model];
while chain.len() < 16
&& let Some(parent) = chain.last().and_then(|m| m.get("parent")?.as_str())
&& let Some(parent) = read_model(root, &namespaced(parent))
{
chain.push(parent);
}
// Parents first, so a child's keys replace theirs.
let mut connections = Connections::Textures;
let mut textures: HashMap<&str, &str> = HashMap::new();
for model in chain.iter().rev() {
match Connections::of(model) {
Ok(own) => connections = own.inherit(&connections),
Err(e) => log::warn!("Horizon: Fusion model {name}: {e}"),
}
for (key, value) in model
.get("textures")
.and_then(Json::as_object)
.unwrap_or(&[])
{
// A sprite, or an object naming one (`{"sprite": ..., "force_translucent": true}`).
let value = value.as_str().or_else(|| value.get("sprite")?.as_str());
if let Some(value) = value {
textures.insert(key.as_str(), value);
}
}
}
// A `#key` as vanilla resolves it.
let resolve = |reference: &str| {
let mut value = reference;
for _ in 0..16 {
match value.strip_prefix('#') {
Some(key) => value = textures.get(key)?,
None => return Some(namespaced(value)),
}
}
None
};
// The elements are the nearest model's that has some.
let elements = chain
.iter()
.find_map(|m| m.get("elements")?.as_array())
.unwrap_or(&[]);
let mut out: Vec<ModelFace> = Vec::new();
let faces = elements
.iter()
.filter_map(|e| e.get("faces")?.as_object())
.flatten();
for (side, face) in faces {
let side = match side.as_str() {
"down" => Face::Down,
"up" => Face::Up,
"north" => Face::North,
"south" => Face::South,
"west" => Face::West,
"east" => Face::East,
_ => continue,
};
let Some(texture) = face.get("texture").and_then(Json::as_str) else {
continue;
};
let Some(sprite) = resolve(texture) else {
continue;
};
if !out.iter().any(|(s, f, _)| *s == sprite && *f == side) {
let key = face.get("connections").and_then(Json::as_str);
out.push((sprite, side, connections.for_face(key, texture).cloned()));
}
}
out
}
/// Where a model's `face` points once a blockstate turns it `x`, then `y` quarter turns.
fn turned(face: Face, [x, y, _]: [u8; 3]) -> Face {
let mut n = face.normal();
for _ in 0..x % 4 {
// x=90 takes up to north.
n = [n[0], n[2], -n[1]];
}
for _ in 0..y % 4 {
// y=90 takes north to east.
n = [-n[2], n[1], n[0]];
}
Face::ALL
.into_iter()
.find(|f| f.normal() == n)
.expect("a quarter turn maps faces onto faces")
}
/// The connecting textures under `root`, by sprite name.
fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
let mut out = HashMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
out
}
/// `sprites`' tiles of the connecting texture `name`, and the predicates it joins by itself.
fn sheet(
root: &Path,
sprites: &mut Sprites,
name: &str,
meta: Meta,
) -> Option<(Sheet, Connection)> {
let Kind::Connecting {
layout,
connections,
per_tile_animation,
..
} = meta.kind
else {
return None;
};
let tiles = sprites
.insert_tiles(root, name, layout.grid(), per_tile_animation)
.map_err(|e| log::warn!("Horizon: Fusion texture {name}: {e}"))
.ok()?;
// A layer past the mesher's `u16` leaves the sheet to vanilla.
let tiles = tiles
.into_iter()
.map(|t| match t {
None => Some(None),
Some(l) => Some(Some(TileLayer::Layer {
layer: u16::try_from(l).ok()?,
translucent: sprites.translucent[l as usize],
opaque: sprites.opaque[l as usize],
})),
})
.collect::<Option<Vec<_>>>()?;
Some((Sheet { layout, tiles }, connections))
}
impl FusionTextures {
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
/// shows one gets its rule.
pub fn load(
root: &Path,
registry: &BlockRegistry,
sprites: &mut Sprites,
catalog: &mut StateModels,
) -> Self {
let mut textures = connecting_textures(root);
if textures.is_empty() {
return Self::default();
}
// The layers models show the sheets as: only their models are read.
let shown: std::collections::HashSet<u32> = textures
.keys()
.filter_map(|name| sprites.layers.get(name).copied())
.collect();
let mut models: HashMap<String, Vec<ModelFace>> = HashMap::new();
let mut sheets: Vec<(Sheet, Connection)> = Vec::new();
let mut by_name: HashMap<String, Option<usize>> = HashMap::new();
let mut rules: Vec<(usize, Connection)> = Vec::new();
for (variant, model) in catalog.models_mut() {
if !model.faces.iter().any(|f| shown.contains(&f.layer)) {
continue;
}
let found = models
.entry(namespaced(&variant.model))
.or_insert_with_key(|name| connecting_model(root, name));
for face in &mut model.faces {
let sprite = &face.quad.material.sprite;
// The model's face on the side this one turned to, else any with its sprite.
let side = Face::ALL[face.quad.facing as usize];
let mut drawing = found.iter().filter(|(s, _, _)| s == sprite);
let Some((_, _, connection)) = drawing
.clone()
.find(|(_, f, _)| turned(*f, variant.rotations) == side)
.or_else(|| drawing.next())
else {
continue;
};
// The sheet, cut into tiles on first use.
let sheet = *by_name.entry(sprite.clone()).or_insert_with(|| {
let meta = textures.remove(sprite)?;
sheets.push(sheet(root, sprites, sprite, meta)?);
Some(sheets.len() - 1)
});
let Some(sheet) = sheet else {
continue;
};
let connection = connection
.clone()
.unwrap_or_else(|| sheets[sheet].1.clone());
let rule = match rules.iter().position(|r| r.0 == sheet && r.1 == connection) {
Some(rule) => rule,
None => {
rules.push((sheet, connection));
rules.len() - 1
}
};
face.fusion = u16::try_from(rule + 1).ok().and_then(NonZeroU16::new);
}
}
let states = (0..registry.len() as u32).map(|id| {
registry
.state(BlockStateId(id))
.map(|s| (s.name.as_str(), &s.properties))
});
let sheets = sheets.into_iter().map(|(sheet, _)| sheet).collect();
Self::build(sheets, rules, states)
}
/// The lookups for `rules` over every block state (`None` for an id the registry skips).
fn build<'a>(
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
) -> Self {
let states: Vec<_> = states.collect();
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
let mut names = HashMap::new();
let blocks = (0..states.len() as u32)
.map(|id| match states[id as usize] {
Some((name, _)) => {
block_states.entry(name).or_default().push(id);
let next = names.len() as u32;
*names.entry(name).or_insert(next)
}
None => u32::MAX,
})
.collect();
// Block lists to the states they match.
let mut resolve = |list: Vec<horizon_optifine::BlockMatch>| {
let mut ids: Vec<u32> = list
.iter()
.flat_map(|m| {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
candidates.iter().copied().filter(|&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
})
})
.collect();
ids.sort_unstable();
ids.dedup();
ids
};
let rules = rules
.into_iter()
.map(|(sheet, connection)| Rule {
sheet,
connection: connection.map_blocks(&mut resolve),
})
.collect();
Self {
sheets,
rules,
blocks,
}
}
/// What the `face` of the block `state` draws for a quad whose rule is `rule` and whose
/// tint index is `tint`. `neighbour` answers the state at an offset from the block,
/// `covers` whether a state hides the face of a block beside it; the overlay layout's
/// pieces, tinted as the quad, go to `overlay`.
#[allow(clippy::too_many_arguments)]
pub fn pick(
&self,
rule: NonZeroU16,
state: u32,
face: Face,
tint: i32,
neighbour: impl Fn([i32; 3]) -> u32,
covers: impl Fn(u32) -> bool,
mut overlay: impl FnMut(Overlay),
) -> Option<Pick> {
let rule = self.rules.get(usize::from(rule.get() - 1))?;
let normal = face.normal();
let offsets = connect::offsets(Axes::cube(face));
let block = self.blocks.get(state as usize).filter(|&&b| b != u32::MAX);
let mask = rule.connection.mask(|bit, query| {
let &(_, [x, y, z]) = offsets.iter().find(|o| o.0 == bit).expect("a mask bit");
let in_front = || neighbour([x + normal[0], y + normal[1], z + normal[2]]);
match query {
Query::SameBlock => {
block.is_some() && self.blocks.get(neighbour([x, y, z]) as usize) == block
}
Query::SameState => neighbour([x, y, z]) == state,
Query::Neighbour(ids) => ids.binary_search(&neighbour([x, y, z])).is_ok(),
Query::InFront(ids) => ids.binary_search(&in_front()).is_ok(),
Query::FaceVisible => !covers(in_front()),
}
});
let sheet = &self.sheets[rule.sheet];
let tile = |i: u8| sheet.tiles.get(usize::from(i)).copied().flatten();
// An empty tile draws nothing: a pack leaves empty only the tiles its layout's uses
// never need.
Some(match sheet.layout.tiles(mask) {
Tiles::One(t) => tile(t).map_or(Pick::Nothing, Pick::Tile),
Tiles::Quarters(q) => match q.map(tile) {
[Some(a), Some(b), Some(c), Some(d)] => Pick::Quarters([a, b, c, d]),
_ => Pick::Nothing,
},
Tiles::Overlay(mask) => {
for t in Layout::overlay_tiles(mask) {
if let Some(TileLayer::Layer {
layer, translucent, ..
}) = tile(t)
{
overlay(Overlay {
layer,
translucent,
tint: (tint >= 0).then_some((tint, None)),
});
}
}
Pick::Nothing
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Tile `i` of a sheet is layer `base + i`; `empty` tiles have none.
fn sheet(layout: Layout, base: u16, empty: &[u16]) -> Sheet {
let (w, h) = layout.grid();
Sheet {
layout,
tiles: (0..(w * h) as u16)
.map(|i| (!empty.contains(&i)).then_some(layer(base + i)))
.collect(),
}
}
fn predicate(json: &str) -> Connection {
Connection::parse(&Json::parse(json).unwrap()).unwrap()
}
fn layer(layer: u16) -> TileLayer {
TileLayer::Layer {
layer,
translucent: false,
opaque: false,
}
}
/// The lookups over blocks named `names` (a state each).
fn world(
names: &[&str],
sheets: Vec<Sheet>,
rules: Vec<(usize, Connection)>,
) -> FusionTextures {
let none = BlockProperties::default();
let states = names.iter().map(|n| Some((*n, &none)));
FusionTextures::build(sheets, rules, states)
}
/// `pick` for the block at the origin among `blocks` (offset, state), the opaque ones in
/// `opaque`; with the overlays it hands out.
fn pick_in(
fusion: &FusionTextures,
rule: u16,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
opaque: &[u32],
) -> (Option<Pick>, Vec<u16>) {
// A face's rule counts from 1.
let rule = NonZeroU16::new(rule + 1).unwrap();
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut overlays = Vec::new();
let pick = fusion.pick(
rule,
state,
face,
-1,
at,
|s| opaque.contains(&s),
|o| overlays.push(o.layer),
);
(pick, overlays)
}
#[test]
fn glass_pieces_join_the_same_block() {
// Glass twice (two states of one block), then stone.
let fusion = world(
&[
"minecraft:air",
"minecraft:glass",
"minecraft:glass",
"minecraft:stone",
],
vec![sheet(Layout::Pieced, 100, &[])],
vec![(0, Connection::SameBlock)],
);
let at = |blocks: &[([i32; 3], u32)]| pick_in(&fusion, 0, 1, Face::North, blocks, &[]).0;
// Alone: every quarter the lone tile.
assert_eq!(at(&[]), Some(Pick::Quarters([layer(100); 4])));
// A north face's right is west: glass there (in its other state) joins the right
// quarters, the horizontal piece. Stone doesn't.
let right = Pick::Quarters([layer(100), layer(103), layer(100), layer(103)]);
assert_eq!(at(&[([-1, 0, 0], 2)]), Some(right));
assert_eq!(
at(&[([-1, 0, 0], 3)]),
Some(Pick::Quarters([layer(100); 4]))
);
// A rule that doesn't exist draws as vanilla.
assert_eq!(pick_in(&fusion, 1, 1, Face::North, &[], &[]).0, None);
}
#[test]
fn overlays_reach_toward_visible_sand() {
let sand = predicate(
r#"{"type": "and", "predicates": [{"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]}"#,
);
let fusion = world(
&["minecraft:air", "minecraft:stone", "minecraft:sand"],
vec![sheet(Layout::Overlay, 200, &[7])],
vec![(0, sand)],
);
// Sand to the north of stone's top face, its own top open: the top edge piece, (1, 2),
// over a face that draws nothing itself.
let north = [([0, 0, -1], 2)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &north, &[]),
(Some(Pick::Nothing), vec![213])
);
// Stone over that sand hides its top: no edge.
let covered = [([0, 0, -1], 2), ([0, 1, -1], 1)];
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &covered, &[1]),
(Some(Pick::Nothing), vec![])
);
// Sand all round: the ring, (4, 1).
let ring: Vec<_> = [-1, 0, 1]
.into_iter()
.flat_map(|x| [-1, 0, 1].map(|z| ([x, 0, z], 2)))
.filter(|(o, _)| *o != [0, 0, 0])
.collect();
assert_eq!(pick_in(&fusion, 0, 1, Face::Up, &ring, &[]).1, [210]);
// Its pieces take the quad's tint index.
let mut tints = Vec::new();
let at = |o: [i32; 3]| if o == [0, 0, -1] { 2 } else { 0 };
let rule = NonZeroU16::new(1).unwrap();
fusion.pick(rule, 1, Face::Up, 0, at, |_| false, |o| tints.push(o.tint));
assert_eq!(tints, [Some((0, None))]);
}
#[test]
fn in_front_and_empty_tiles() {
// Dirt's top joins where sand sits on the block beside it; its full sheet leaves the
// lone tile empty.
let front = predicate(r#"{"type": "match_block_in_front", "block": "sand"}"#);
let fusion = world(
&["minecraft:air", "minecraft:dirt", "minecraft:sand"],
vec![sheet(Layout::Full, 300, &[0])],
vec![(0, front)],
);
// Sand on the block east of the top face: joined right, the full template's (1, 0).
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 1, 0], 2)], &[]).0,
Some(Pick::Tile(layer(301)))
);
// Sand beside it, not in front of anything: alone, and the lone tile is empty.
assert_eq!(
pick_in(&fusion, 0, 1, Face::Up, &[([1, 0, 0], 2)], &[]).0,
Some(Pick::Nothing)
);
}
#[test]
fn models_name_the_predicates_their_faces_draw() {
let root =
std::env::temp_dir().join(format!("horizon-fusion-models-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// A pane arm over a template whose `particle` names the pane sheet but draws no face
// with it (as Fusion Connected Glass's posts do): only the faces count.
fs::write(
models.join("template.json"),
r##"{"textures": {"particle": "#pane"}, "elements": [{"from": [7, 0, 0], "to": [9, 16, 7],
"faces": {"up": {"texture": "#edge"}, "west": {"texture": "#pane"}}}]}"##,
)
.unwrap();
fs::write(
models.join("arm.json"),
r#"{"loader": "fusion:model", "type": "fusion:connecting", "parent": "demo:block/template",
"connections": {"edge": {"type": "true"}},
"textures": {"pane": "demo:block/glass", "edge": {"sprite": "demo:block/edge"}}}"#,
)
.unwrap();
fs::write(
models.join("plain.json"),
r#"{"parent": "demo:block/template", "textures": {"pane": "demo:block/glass"}}"#,
)
.unwrap();
let arm = connecting_model(&root, "demo:block/arm");
let plain = connecting_model(&root, "demo:block/plain");
fs::remove_dir_all(&root).unwrap();
// `edge` has the model's predicate; `pane` none, so the texture's own.
assert_eq!(
arm,
[
(
"demo:block/edge".to_owned(),
Face::Up,
Some(Connection::Const(true))
),
("demo:block/glass".to_owned(), Face::West, None),
]
);
// Not a Fusion model: vanilla.
assert!(plain.is_empty());
}
#[test]
fn faces_of_one_sprite_join_by_their_own_keys() {
let root = std::env::temp_dir().join(format!("horizon-fusion-keys-{}", std::process::id()));
let models = root.join("assets/demo/models/block");
fs::create_dir_all(&models).unwrap();
// The wiki's example: four keys on one texture, two of them joining by `blue`.
fs::write(
models.join("tiles.json"),
r##"{"loader": "fusion:model", "type": "connecting",
"textures": {"ice": "block/oak_tiles", "dirt": "block/oak_tiles", "stone": "block/oak_tiles"},
"connections": {"blue": {"type": "match_block", "block": "ice"},
"dirt": {"type": "is_same_block"}, "default": {"type": "is_same_state"}},
"elements": [{"from": [0, 0, 0], "to": [16, 16, 16], "faces": {
"up": {"texture": "#ice", "connections": "#blue"},
"north": {"texture": "#dirt"}, "south": {"texture": "#stone"}}}]}"##,
)
.unwrap();
let faces = connecting_model(&root, "demo:block/tiles");
fs::remove_dir_all(&root).unwrap();
let joins = |side| faces.iter().find(|f| f.1 == side).and_then(|f| f.2.clone());
assert!(matches!(joins(Face::Up), Some(Connection::Neighbour(_))));
assert_eq!(joins(Face::North), Some(Connection::SameBlock));
assert_eq!(joins(Face::South), Some(Connection::SameState));
// A blockstate's turns move them: x=90 takes the top to the north, y=90 the north to
// the east.
assert_eq!(turned(Face::Up, [1, 0, 0]), Face::North);
assert_eq!(turned(Face::North, [0, 1, 0]), Face::East);
assert_eq!(turned(Face::Up, [1, 1, 0]), Face::East);
}
}
File diff suppressed because it is too large. Load diff
+34 -216
View File
@@ -157,24 +157,21 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, LARGE_PAGE_BYTES)
}
/// The sorted-index arena and, when `split`, the translucent arena
/// (vertices of `stride`) with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena. The indices go in
/// large pages too, unless flat draws (`flat`) bind their pages as storage.
/// (vertices of `stride`), both with large pages, which no shader binds;
/// otherwise translucent quads stay in the section arena, and the indices
/// in pages flat draws (`flat`) bind as storage.
pub fn translucent_arenas(
device: &wgpu::Device,
stride: u64,
flat: bool,
split: bool,
) -> (Self, Option<Self>) {
let indices = if split && !flat {
Self::translucent_indices(device, 1 << 16)
if split {
let indices = Self::translucent_indices(device, 1 << 16);
(indices, Some(Self::translucent(device, stride, 1 << 18)))
} else {
Self::indices(device, 1 << 16, flat)
};
(
indices,
split.then(|| Self::translucent(device, stride, 1 << 18)),
)
(Self::indices(device, 1 << 16, flat), None)
}
}
fn with_usage(
device: &wgpu::Device,
@@ -215,13 +212,12 @@ impl Arena {
spill: bool,
) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
let (label, bytes) = ("Section buffer arena", capacity * self.stride);
#[cfg(test)]
let local = (!self.video_memory_full)
.then(|| try_create(device, label, bytes, usage))
.then(|| try_create(device, capacity * self.stride, usage))
.flatten();
#[cfg(not(test))]
let local = try_create(device, label, bytes, usage);
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
@@ -233,7 +229,11 @@ impl Arena {
{
return None;
}
let buffer = try_create(device, label, bytes, usage | wgpu::BufferUsages::MAP_WRITE)?;
let buffer = try_create(
device,
capacity * self.stride,
usage | wgpu::BufferUsages::MAP_WRITE,
)?;
if !std::mem::replace(&mut self.spilled, true) {
log::warn!(
"Horizon: video memory is full; more terrain is kept in system memory \
@@ -503,17 +503,12 @@ impl Arena {
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
}
for (page, ranges) in by_page.into_iter().enumerate() {
if ranges.is_empty() {
continue;
}
let page = self.pages[page].as_mut().expect("live arena page");
// Every range of the page given back (a far teleport empties the arena): one
// free range, without sorting tens of thousands into the list.
let given: u64 = ranges.iter().map(|r| r.1).sum();
if page.free_vertices() + given == page.capacity {
page.free = FreeList::new(page.capacity);
} else {
page.free.give_all(ranges);
if !ranges.is_empty() {
self.pages[page]
.as_mut()
.expect("live arena page")
.free
.give_all(ranges);
}
}
}
@@ -633,15 +628,10 @@ pub fn free_later<T: Send + 'static>(items: Vec<T>) {
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
pub(super) fn try_create(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> Option<wgpu::Buffer> {
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
let scope = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
label: Some("Section buffer arena"),
size,
usage,
mapped_at_creation: false,
@@ -753,41 +743,6 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
/// Released together, every range of a page leaves it one free range (a far teleport
/// empties the arena at once), while a page keeping some ranges frees only the others.
#[test]
fn ranges_released_at_once_free_exactly_their_vertices() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
let ranges: Vec<(u32, u64)> = (0..8)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// All four of the first page, the middle two of the second.
let released: Vec<(u32, u64, u64)> = ranges
.iter()
.enumerate()
.filter(|&(i, _)| i < 4 || i == 5 || i == 6)
.map(|(_, &(page, start))| (page, start, GRAIN))
.collect();
arena.release_all(released);
let free = |page: u32| -> Vec<(u64, u64)> {
let free = &arena.pages[page as usize].as_ref().unwrap().free.0;
free.iter().map(|(&start, &len)| (start, len)).collect()
};
assert_eq!(free(first), vec![(0, 4 * GRAIN)]);
assert_eq!(free(second), vec![(ranges[5].1, 2 * GRAIN)]);
// The emptied page takes a whole page's range again, without growing.
let pages = arena.pages.iter().flatten().count();
assert_eq!(arena.alloc_existing(4 * GRAIN), Some((first, 0)));
assert_eq!(arena.pages.iter().flatten().count(), pages);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
@@ -862,11 +817,10 @@ mod tests {
/// section holds in the section arena, data and all. Split, that is its
/// solid quads alone, and nothing of a section without any, whose (0, 0)
/// names no range of page 0; else solid and translucent quads together,
/// `translucent_at` along. GPU culling's records of the sections with solid
/// quads move with them.
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{Part, SectionGpu, gpu_culling, move_off_page};
use super::super::{Part, SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -882,7 +836,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
gpu_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -930,15 +884,6 @@ mod tests {
.iter()
.map(|s| (s.page, s.start, s.translucent_at))
.collect();
// The sections with solid quads have records, on page 0.
let mut records = gpu_culling::Records::default();
for (i, s) in sections.iter_mut().enumerate() {
if s.solid > 0 {
s.gpu_id = Some(records.insert((i as i32, 0, 0), s.page, s.start, s.solid));
}
}
let recorded = records.live() as u32;
assert_eq!(records.page_sections(), [recorded], "split {split}");
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
@@ -947,7 +892,6 @@ mod tests {
&mut arena,
&mut encoder,
None,
Some(&mut records),
meshes,
0,
Part::Section { split },
@@ -957,11 +901,6 @@ mod tests {
let word =
|(page, at): (u32, u64)| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in sections.iter().enumerate() {
// Its record names its solid quads where they are now.
let record = (s.solid > 0)
.then(|| gpu_culling::Record::new((i as i32, 0, 0), s.page, s.start, s.solid));
let held = s.gpu_id.and_then(|id| records.at(id));
assert_eq!(held, record.as_ref(), "split {split}, section {i}");
let Some((page, start, len)) = s.vertex_ranges(split).0 else {
assert_eq!(
(s.page, s.start, s.translucent_at),
@@ -980,8 +919,6 @@ mod tests {
}
}
}
// Page 0's count went to page 1 with them.
assert_eq!(records.page_sections(), [0, recorded], "split {split}");
// Page 0 empties without a range given twice, and page 1 took a grain
// per section moved: one is left.
arena.trim();
@@ -991,123 +928,6 @@ mod tests {
}
}
/// Flat draws find a section's origin by the id its blocks name, in the section arena's
/// tables and, split, in the translucent arena's (`flat::TRANSLUCENT_KEYS`). Translucent
/// quads a shrink moves (`move_off_page`) keep naming their section, or flat draws would
/// put them at another section's origin.
#[test]
fn moved_translucent_quads_keep_their_section_s_flat_id() {
use super::super::{Part, SectionGpu, flat, move_off_page, upload::Uploads};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }"
.into(),
),
});
let terrain_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
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;
flat.set_translucent_page_vertices(translucent.page_vertices());
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// Page 0: another section's grain, then this one's two; page 1: a grain taken.
let (_, other) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
let (_, at) = alloc(&mut translucent, &device, &queue, 2 * GRAIN).unwrap();
let (_, rest) = alloc(&mut translucent, &device, &queue, GRAIN).unwrap();
assert_eq!(
alloc(&mut translucent, &device, &queue, GRAIN),
Some((1, 0))
);
translucent.release(0, rest, GRAIN);
let key = |page: u32| page + flat::TRANSLUCENT_KEYS;
let other_id = flat.add(&device, &mut encoder, &mut uploads, key(0), other, GRAIN);
// Its solid quads in the section arena's page 0, its translucent ones here.
let id = flat.add(&device, &mut encoder, &mut uploads, 0, 0, GRAIN);
flat.place(
&device,
&mut encoder,
&mut uploads,
id,
key(0),
at,
2 * GRAIN,
);
let mut section = SectionGpu {
page: 0,
start: 0,
translucent_at: (0, at),
solid: 64,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
translucent.close(0);
let mut budget = u64::MAX;
assert!(move_off_page(
&mut translucent,
&mut encoder,
Some((&mut flat, &device, &mut uploads)),
None,
std::iter::once(&mut section),
0,
Part::Translucent,
&mut budget
));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let (page, start) = section.translucent_at;
assert_eq!(page, 1);
assert_eq!(section.flat_id, Some(id));
let named =
|page: u32, grain: u64| read(&device, &queue, flat.blocks(key(page)), grain * 4);
for grain in start / GRAIN..(start / GRAIN + 2) {
assert_eq!(named(1, grain), id, "grain {grain} of its new range");
}
// The section arena's table and the other section's grain are untouched.
assert_eq!(read(&device, &queue, flat.blocks(0), 0), id);
assert_eq!(named(0, other / GRAIN), other_id);
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
@@ -1209,9 +1029,9 @@ mod tests {
}
/// The translucent arena comes with the split layout alone, and only then
/// are the index pages large, unless flat draws (`flat`) bind them as
/// storage. Otherwise, as while a shader pack's terrain draws flat (gfx's
/// `translucent_split`), translucent quads keep to the section arena.
/// are the index pages large. Otherwise, as while a shader pack's terrain
/// draws flat (gfx's `translucent_split`), translucent quads keep to the
/// section arena, and flat draws (`flat`) bind the index pages as storage.
#[test]
fn translucent_arenas_follow_the_layout() {
let devices = devices();
@@ -1231,9 +1051,9 @@ mod tests {
assert_eq!(translucent.is_some(), split, "{case}");
let bytes = indices.page_vertices() * 4;
let storage = indices.usage.contains(wgpu::BufferUsages::STORAGE);
assert_eq!(storage, flat, "{case}");
assert_eq!(storage, flat && !split, "{case}");
assert!(!storage || bytes <= limits.max_storage_buffer_binding_size);
assert_eq!(bytes > PAGE_BYTES, split && !flat && large, "{case}");
assert_eq!(bytes > PAGE_BYTES, split && large, "{case}");
if let Some(t) = translucent {
assert_eq!(t.page_vertices() * 24 > PAGE_BYTES, large, "{case}");
}
@@ -1300,7 +1120,7 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1353,9 +1173,7 @@ mod tests {
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
assert!(move_off_page(
arena, encoder, None, None, held, 0, part, budget
));
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
meshes.iter().filter(|(_, s)| on(s)).count()
};
@@ -1415,7 +1233,7 @@ mod tests {
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
// `oversized` looks at the counts only: a small buffer stands in.
let buffer = try_create(&device, "Stand-in page", 4, wgpu::BufferUsages::VERTEX).unwrap();
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
let mut shrinks = |capacity: u64, used: u64, host: bool| {
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
let buffer = buffer.clone();
@@ -29,8 +29,6 @@ pub(super) struct EntityOutline {
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
/// The silhouettes posed on the GPU: first vertex in the entity buffer, quads.
posed: (u32, u32),
targets: Option<Targets>,
}
@@ -161,7 +159,6 @@ impl EntityOutline {
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
posed: (0, 0),
targets: None,
}
}
@@ -178,20 +175,16 @@ impl EntityOutline {
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
/// `posed`: where the silhouettes posed on the GPU start in the entity
/// buffer (`entity_skin`'s outline jobs), and how many quads they are.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
posed: (u32, u32),
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
self.posed = posed;
if self.quads == 0 && posed.1 == 0 {
if self.quads == 0 {
self.targets = None;
return;
}
@@ -200,14 +193,12 @@ impl EntityOutline {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
if !silhouettes.is_empty() {
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
@@ -269,14 +260,9 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
entities: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self
.targets
.as_ref()
.filter(|_| self.quads > 0 || self.posed.1 > 0)
else {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
@@ -291,15 +277,8 @@ impl EntityOutline {
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if self.quads > 0 {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
let (first, quads) = self.posed;
if quads > 0 {
pass.set_vertex_buffer(0, entities.slice(..));
pass.draw_indexed(0..quads * 6, first as i32, 0..1);
}
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
@@ -1,811 +0,0 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path. Item models come too ([`SkinJob::item`]):
//! their quads all face an axis, and their shading is baked into the colour.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
impl SkinPart {
/// An item layer's part: its pose, and its quads' colours by axis.
pub fn item(matrix: glam::Mat4, colors: [u32; 6]) -> Self {
let mut normal = [0.0; 9];
for (word, color) in normal.iter_mut().zip(colors) {
*word = f32::from_bits(color);
}
Self {
matrix: matrix.to_cols_array(),
normal,
}
}
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
/// How the vertices are finished: [`SkinJob::MODEL`], [`SkinJob::ITEM`] or
/// [`SkinJob::FLAT`].
pub mode: u32,
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
pub cull: u32,
/// Always 0: the shader reads it as a zero it cannot fold away.
pub spare: u32,
}
impl SkinJob {
/// A model's polygons: the face normal posed, the job's colour.
pub const MODEL: u32 = 0;
/// An item model's quads (`item_render::WorldSink`): the colour by the face's
/// axis rides in the part's first 6 normal words ([`SkinPart::item`]), shaded on
/// the CPU, and the normal is `mesher::CULL_BACK`.
pub const ITEM: u32 = 1;
/// A model's polygons with no normal at all (all four bytes 0): the eyes render
/// type's layers, which entity.wgsl leaves unshaded.
pub const FLAT: u32 = 2;
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Shader identities in the GPU output vertex space; empty without a pack.
pub spans: Vec<super::pack_draw::EntitySpan>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
/// Glowing entities' silhouettes posed here (`OutlineBufferSource`'s copy of
/// their draws, [`SkinFrame::outline_since`]): `dst` counts from the first.
pub outline_jobs: Vec<SkinJob>,
/// Vertices the outline jobs write.
pub outline_vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.spans.clear();
self.vertices = 0;
self.outline_jobs.clear();
self.outline_vertices = 0;
}
/// Copies the draws since job `first` into the glowing entities' outline
/// (`Frame::glowing`'s, on the GPU), as `glowing.rs`'s `mark_outline` copies
/// vertices: the same ones, in `color` (`Vertex::color`) with no normal.
pub fn outline_since(&mut self, first: usize, color: u32) {
for i in first..self.jobs.len() {
let job = self.jobs[i];
self.outline_jobs.push(SkinJob {
dst: self.outline_vertices,
color,
mode: SkinJob::FLAT,
..job
});
self.outline_vertices += job.count;
}
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
self.spans.retain(|s| s.start <= vertices);
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset
|| !self.new_model.is_empty()
|| !self.jobs.is_empty()
|| !self.outline_jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on, and the outline jobs `frame.outline_vertices`
/// from vertex `outline_base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
outline_base: u32,
) {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame
.jobs
.iter()
.map(|job| SkinJob {
dst: job.dst + base,
..*job
})
.chain(frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
})),
out,
);
}
/// A standard pack owns the body output; glowing silhouettes keep the ordinary
/// entity buffer. Both routes use the same model/normal/back-face decoder.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch_pack(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
outline_out: &wgpu::Buffer,
outline_base: u32,
) {
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.jobs.iter().copied(),
out,
);
self.dispatch_jobs(
device,
uploads,
encoder,
&frame.parts,
frame.outline_jobs.iter().map(|job| SkinJob {
dst: job.dst + outline_base,
..*job
}),
outline_out,
);
}
#[allow(clippy::too_many_arguments)]
fn dispatch_jobs(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
parts: &[SkinPart],
jobs: impl Iterator<Item = SkinJob>,
out: &wgpu::Buffer,
) {
self.staged.clear();
self.staged.extend(jobs);
if self.staged.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = self.staged.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
count as usize
};
self.staged.resize(padded, SkinJob::default());
self.parts
.ensure(device, std::mem::size_of_val(parts) as u64);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts_buffer), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts_buffer, 0, bytemuck::cast_slice(parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [
model.clone(),
parts_buffer.clone(),
jobs.clone(),
out.clone(),
];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
expand_jobs(model, frame, &frame.jobs, frame.vertices)
}
/// [`expand`] of the frame's outline jobs: the silhouettes they write.
pub fn expand_outline(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
expand_jobs(model, frame, &frame.outline_jobs, frame.outline_vertices)
}
fn expand_jobs(
model: &[SkinVertex],
frame: &SkinFrame,
jobs: &[SkinJob],
vertices: u32,
) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); vertices as usize];
for job in jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
let (color, normal) = if job.mode == SkinJob::ITEM {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else if job.mode == SkinJob::FLAT {
(job.color, [0; 4])
} else {
let w = if job.cull != 0 {
crate::mesher::SHADED_CULL_W
} else {
-127
};
(job.color, [packed[0], packed[1], packed[2], w])
};
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color,
light: job.light,
normal,
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint, and an item's: the shader writes what
/// `expand` does, to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
spans: Vec::new(),
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
// Colours whose bits as floats are NaNs and denormals: copied, never computed.
SkinPart::item(
child * turn,
[
0xff80_4020,
0x7fc0_0001,
0x0000_0001,
0xffff_ffff,
0x7f80_0001,
0x1234_5678,
],
),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
// The bat's encoding: the shader must write -126 like `expand`.
cull: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
// An item's quads: colour by axis from its part, `CULL_BACK` normals.
SkinJob {
first: 0,
count: 24,
part: 4,
dst: 72,
rect: [0.5, 0.25, 0.5625, 0.3125],
light: 0xa0_30,
overlay: 2 << 16,
mode: SkinJob::ITEM,
..Default::default()
},
// An eyes layer: no normal, even over a culled model's encoding; the
// tier and OIT tag ride in the overlay word.
SkinJob {
first: 24,
count: 24,
part: 1,
dst: 96,
rect: [0.125, 0.75, 0.25, 0.875],
color: 0x80ff_ffff,
light: 0xf0f0,
overlay: crate::gfx::OIT_ENTITY | 3 << 16 | 7,
mode: SkinJob::FLAT,
cull: 1,
..Default::default()
},
],
vertices: 120,
outline_jobs: Vec::new(),
outline_vertices: 0,
};
// A glowing entity's silhouette: the item job and the first job again,
// in its team colour with no normal.
let mut frame = frame;
frame.jobs.push(frame.jobs[2]);
frame.jobs.last_mut().expect("pushed").dst = 120;
frame.jobs.push(SkinJob {
dst: 144,
..frame.jobs[0]
});
frame.vertices = 192;
frame.outline_since(4, 0xff20_c0e0);
assert_eq!(frame.outline_vertices, 72);
let expected = expand(&model, &frame);
let outline = expand_outline(&model, &frame);
// The posed vertices land after 5 others, which they leave alone, and the
// silhouettes 3 past them.
let before = 5u32;
let outline_base = before + frame.vertices + 3;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(outline_base + frame.outline_vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(
&device,
&mut uploads,
&mut encoder,
&frame,
&out,
before,
outline_base,
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
let silhouettes = &got[(outline_base - before) as usize..];
assert_eq!(silhouettes.len(), outline.len());
for (i, (got, want)) in silhouettes.iter().zip(&outline).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"silhouette {i}: {got:?} != {want:?}"
);
}
// A silhouette is its draw's vertex with the colour and no normal
// (`mark_outline`).
for (copy, of) in outline.iter().zip(&expected[120..]) {
let marked = Vertex {
color: 0xff20_c0e0,
normal: [0; 4],
..*of
};
assert_eq!(bytemuck::bytes_of(copy), bytemuck::bytes_of(&marked));
}
// Standard packs own the model buffer, while outlines follow unrelated CPU
// geometry in the ordinary buffer. Neither output may overwrite the other's gap.
let buffers = [frame.vertices + 3, before + frame.outline_vertices + 2].map(|vertices| {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
queue.write_buffer(&buffer, 0, &vec![0xab; buffer.size() as usize]);
buffer
});
let reads = buffers.each_ref().map(|buffer| {
device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: buffer.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
});
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
skin.dispatch_pack(
&device,
&mut uploads,
&mut encoder,
&frame,
&buffers[0],
&buffers[1],
before,
);
for (buffer, read) in buffers.iter().zip(&reads) {
encoder.copy_buffer_to_buffer(buffer, 0, read, 0, buffer.size());
}
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for read in &reads {
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
}
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
for (i, (expected, start)) in [(&expected, 0usize), (&outline, before as usize)]
.into_iter()
.enumerate()
{
let data = reads[i].slice(..).get_mapped_range().unwrap();
let first = start * 44;
let last = first + expected.len() * 44;
assert!(data[..first].iter().all(|b| *b == 0xab), "prefix {i}");
assert_eq!(
&data[first..last],
bytemuck::cast_slice::<_, u8>(expected),
"output {i}"
);
assert!(data[last..].iter().all(|b| *b == 0xab), "suffix {i}");
}
}
}
@@ -1,143 +0,0 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), the mode
// (`SkinJob::mode`: 0 a model, 1 an item's quads, 2 a model with no normal),
// whether its quads cull back faces (`SkinJob::cull`), 1 spare (0: `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
// A zero the compiler cannot see (the job's last word). Metal compiles shaders
// with fast math: it would fuse `a * b + c` into one rounding and reorder sums,
// a bit off the CPU's separate operations. A value xored with `keep` is opaque
// to it, so each operation below rounds on its own, in the CPU's order.
var<private> keep: u32;
fn r(v: f32) -> f32 {
return bitcast<f32>(bitcast<u32>(v) ^ keep);
}
fn r3(v: vec3<f32>) -> vec3<f32> {
return bitcast<vec3<f32>>(bitcast<vec3<u32>>(v) ^ vec3(keep));
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = r(u + scroll);
return r(s - r(trunc(s)));
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let mode = jobs[j + 13u];
let cull = jobs[j + 14u] != 0u;
keep = jobs[j + 15u];
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order, each operation rounded.
var pos = r3(vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x);
pos = r3(r3(vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y) + pos);
pos = r3(r3(vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z) + pos);
pos = r3(vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos);
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
var vertex_color = color;
var normal = pack(n);
if cull {
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if mode == 1u {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
} else if mode == 2u {
// The eyes render type's layers: no normal (`SkinJob::FLAT`).
normal = 0u;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + r(r(rect.z - rect.x) * scroll_u(uv.x, scroll)));
out[o + 4u] = bitcast<u32>(rect.y + r(r(rect.w - rect.y) * uv.y));
out[o + 5u] = overlay;
out[o + 6u] = vertex_color;
out[o + 7u] = light;
out[o + 8u] = normal;
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
+54 -95
View File
@@ -48,10 +48,6 @@ struct Plan {
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
/// The page keys of the translucent arena (`Renderer::translucent_arena`, Translucent
/// Batching): its page plus this. The section arena's pages are their own keys, all far
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
struct PageTable {
/// Section id of every block of the page.
@@ -59,19 +55,9 @@ struct PageTable {
group: Option<wgpu::BindGroup>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::REPLACE,
};
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
/// The page's key (`TRANSLUCENT_KEYS`).
pub page: u32,
pub first: u32,
pub count: u32,
@@ -88,7 +74,6 @@ pub struct Flat {
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub translucent_mark: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
@@ -103,9 +88,6 @@ pub struct Flat {
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
pub(super) draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
/// Blocks of a translucent arena page (`TRANSLUCENT_KEYS`), whose pages are larger.
translucent_blocks_per_page: u64,
/// Block tables by page key.
pages: Vec<Option<PageTable>>,
free_ids: Vec<u32>,
next_id: u32,
@@ -226,14 +208,7 @@ fn buffer(
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size.max(16),
// Tests read the tables back.
usage: usage
| wgpu::BufferUsages::COPY_DST
| if cfg!(test) {
wgpu::BufferUsages::COPY_SRC
} else {
wgpu::BufferUsages::empty()
},
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
@@ -248,6 +223,7 @@ fn buffer(
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -257,10 +233,10 @@ fn plan(
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
base: u64,
) -> Plan {
calls.clear();
ranges.clear();
@@ -268,7 +244,7 @@ fn plan(
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut total = 0u64;
let mut total = base;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
if i == phase1_draws {
@@ -289,7 +265,6 @@ fn plan(
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
let page = key(page, sorted);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -445,14 +420,6 @@ impl Flat {
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
translucent_mark: pipeline(
&layout,
"Terrain translucent HDR mark (flat)",
"vs_terrain_flat",
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
),
flatten,
compute_layout,
flatten_seen,
@@ -468,7 +435,6 @@ impl Flat {
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
free_ids: Vec::new(),
next_id: 0,
@@ -497,8 +463,8 @@ impl Flat {
.max(1),
}
}
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
/// its id, which its blocks now name (written through `uploads` in `encoder`).
/// A section uploaded to `vertices` vertices from `start` of `page`: its
/// id, which its blocks now name (written through `uploads` in `encoder`).
pub fn add(
&mut self,
device: &wgpu::Device,
@@ -512,28 +478,6 @@ impl Flat {
self.next_id += 1;
self.next_id - 1
});
self.place(device, encoder, uploads, id, page, start, vertices);
id
}
/// Makes the blocks of `vertices` vertices from `start` of the page keyed `page` name
/// `id`: a section's other range (its translucent quads, in the translucent arena), or
/// a range that moved.
#[allow(clippy::too_many_arguments)]
pub fn place(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
page: u32,
start: u64,
vertices: u64,
) {
let blocks = if page >= TRANSLUCENT_KEYS {
self.translucent_blocks_per_page
} else {
self.blocks_per_page
};
let page = page as usize;
if self.pages.len() <= page {
self.pages.resize_with(page + 1, || None);
@@ -542,7 +486,7 @@ impl Flat {
blocks: buffer(
device,
"Section ids by block",
blocks * 4,
self.blocks_per_page * 4,
wgpu::BufferUsages::STORAGE,
),
group: None,
@@ -555,19 +499,7 @@ impl Flat {
first * 4,
bytemuck::cast_slice(&ids),
);
}
/// The block table of the page keyed `page`, for tests that read it back.
#[cfg(test)]
pub(super) fn blocks(&self, page: u32) -> &wgpu::Buffer {
&self.pages[page as usize]
.as_ref()
.expect("a page table")
.blocks
}
/// The most vertices a page of the translucent arena holds: its block tables' size.
/// Tables made before keep theirs, so this goes with `clear`.
pub fn set_translucent_page_vertices(&mut self, vertices: u64) {
self.translucent_blocks_per_page = vertices.div_ceil(GRAIN);
id
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
@@ -577,7 +509,7 @@ impl Flat {
self.free_ids.clear();
self.next_id = 0;
}
/// Forgets the tables of pages the arenas released (`live`, by page key).
/// Forgets the tables of pages the arena released.
pub fn trim(&mut self, live: impl Fn(u32) -> bool) {
for (i, page) in self.pages.iter_mut().enumerate() {
if page.is_some() && !live(i as u32) {
@@ -608,9 +540,10 @@ impl Flat {
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
mut lists: Option<&mut super::gpu_lists::GpuLists>,
eye: Option<[f32; 3]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
@@ -648,6 +581,8 @@ impl Flat {
bytemuck::cast_slice(&self.origin_data),
);
}
// The GPU-made lists' rooms come first in the index list.
let room = lists.as_mut().map_or(0, |l| l.rooms());
let Plan {
phase1_calls,
solid_calls,
@@ -662,14 +597,10 @@ impl Flat {
shadow,
passes,
self.range_align,
// Translucent draws, and only they, have sorted indices.
|page, sorted| match sorted {
Some(_) if translucent_arena => page + TRANSLUCENT_KEYS,
_ => page,
},
&mut self.calls,
&mut self.range_data,
&mut self.segments,
room,
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
@@ -732,6 +663,9 @@ impl Flat {
bytemuck::cast_slice(&self.phase2_call_data),
);
}
if let Some(lists) = &mut lists {
lists.prepare(device, encoder, uploads, Some(&self.indices), eye);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
@@ -801,13 +735,17 @@ impl Flat {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
if let Some(lists) = &lists {
lists.dispatch(&mut pass);
}
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
for page in self.calls.iter().map(|c| c.page) {
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
@@ -848,6 +786,27 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
/// this frame's `prepare`) with a flat pipeline set.
pub fn draw_lists<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
lists: &super::gpu_lists::GpuLists,
group: u8,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
if !lists.prepared() {
return;
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in lists.calls().iter().enumerate() {
if call.group == group {
self.bind_page(pass, call.page, &vertices);
lists.draw_indexed(pass, k);
}
}
}
/// The camera's translucent calls.
pub fn translucent_calls(&self) -> &[Call] {
&self.calls[self.solid_calls..self.shadow_calls[0]]
@@ -859,7 +818,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
self.draw_with(pass, calls, vertices, |_, _| {});
}
@@ -869,7 +828,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -886,7 +845,7 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
instances: u32,
) {
@@ -900,7 +859,7 @@ impl Flat {
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -915,13 +874,13 @@ impl Flat {
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> wgpu::BufferSlice<'a>,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page));
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_bind_group(1, table, &[]);
}
}
@@ -970,10 +929,10 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
@@ -1019,10 +978,10 @@ mod tests {
[draws.len(); 2],
[3, 1],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
let call = |page, first, count| Call { page, first, count };
@@ -1055,10 +1014,10 @@ mod tests {
[camera, camera + 2],
[camera, 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
let call = |page, first, count| Call { page, first, count };
@@ -1084,10 +1043,10 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(
plan,
@@ -1202,10 +1161,10 @@ mod tests {
[draws.len(); 2],
[draws.len(), 0],
align,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
0,
);
// Phase 2's draws as the test leaves them: instance counts of 0,
// 1 where it saw the section.
File diff suppressed because it is too large. Load diff
@@ -1,203 +0,0 @@
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
// `cull`: one invocation per section id keeps the sections the CPU's walk
// chose, writes each one's origin and counts it in its page, per block of 64
// ids. `scan`: one workgroup per page turns the blocks' counts into where
// each block's draws start, and the page's count. `scatter`: each block
// writes its sections' draws there, in id order, so a page's draws come in
// the same order every frame (atomics alone would order them as the GPU
// schedules, and where faces of two sections meet at the same depth the later
// draw keeps the pixel). Nothing here computes with floats: the origins are
// the CPU's bits, copied.
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
// regions cover, and where the regions and the chosen ids' bits start in
// `tables`.
ids: u32,
updates: u32,
pages: u32,
regions: u32,
mask: u32,
pad0: u32,
pad1: u32,
pad2: u32,
// The tables' first column: x, 0, z, 0.
first: vec4<i32>,
// Their entries along x, y and z, and 0.
cells: vec4<u32>,
}
// gpu_culling.rs `Record`.
struct Section {
// Section x, y (from the level's bottom), z, and its arena page (-1: a
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, then two words
// unused here.
a: vec4<u32>,
}
// gpu_culling.rs `Update`: the record for id `id.x`.
struct Update {
id: vec4<u32>,
section: Section,
}
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
// gpu_culling.rs `GpuCulling::prepare`: the origins' f32 bits along x, y and
// z, each page's region of `draws` (its first draw and its size), then a bit
// per id the CPU's walk chose.
@group(0) @binding(2) var<storage, read> tables: array<u32>;
@group(0) @binding(3) var<storage, read> updates: array<Update>;
// Sections kept, draws, each page's draws, then per block of ids and page its
// draws (`cull`), turned into where they start (`scan`).
@group(0) @binding(4) var<storage, read_write> counts: array<atomic<u32>>;
// DrawIndexedIndirectArgs, five words each.
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
// Per id its page + 1 when it is drawn this frame, else 0.
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
// gfx.rs `section_instance`'s w for a whole section: 1 / mesher::UV_STEPS.
const STEP_BITS: u32 = 0x38000000u;
// Workgroups along x when the ids need more than one row of them.
const ROW: u32 = 65535u;
// Ids per block.
const BLOCK: u32 = 64u;
// Counts before the pages'.
const STATS: u32 = 2u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
return STATS + params.pages + block * params.pages + page;
}
// `page`'s region of `draws`: its first draw and its size.
fn region(page: u32) -> vec2<u32> {
let at = params.regions + 2u * page;
return vec2<u32>(tables[at], tables[at + 1u]);
}
// The CPU's walk chose it (gpu_culling.rs `GpuCulling::choose`).
fn chosen(id: u32) -> bool {
return ((tables[params.mask + id / 32u] >> (id % 32u)) & 1u) != 0u;
}
// One invocation per changed record; the CPU lists each id once.
@compute @workgroup_size(64)
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
if gid.x < params.updates {
let u = updates[gid.x];
sections[u.id.x] = u.section;
}
}
@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.y * (ROW * BLOCK) + gid.x;
if id >= params.ids {
return;
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !chosen(id) {
return;
}
let page = u32(s.key.w);
// The tables hold every column within the distance; a key before their
// first wraps past their end.
let c = vec3<u32>(s.key.xyz - params.first.xyz);
if page >= params.pages || any(c >= params.cells.xyz) {
return;
}
let nx = params.cells.x;
let ny = params.cells.y;
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
runs[id] = page + 1u;
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
}
var<workgroup> partial: array<u32, 256>;
// One workgroup per page: each lane sums a stretch of blocks, the lanes'
// sums are scanned, and each block's sum becomes where its draws start.
@compute @workgroup_size(256)
fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let page = group.x;
let blocks = (params.ids + BLOCK - 1u) / BLOCK;
let stretch = (blocks + 255u) / 256u;
let first = min(lane * stretch, blocks);
let last = min(first + stretch, blocks);
var sum = 0u;
for (var b = first; b < last; b++) {
sum += atomicLoad(&counts[block_count(b, page)]);
}
partial[lane] = sum;
workgroupBarrier();
for (var step = 1u; step < 256u; step <<= 1u) {
var before = 0u;
if lane >= step {
before = partial[lane - step];
}
workgroupBarrier();
partial[lane] += before;
workgroupBarrier();
}
var start = 0u;
if lane > 0u {
start = partial[lane - 1u];
}
for (var b = first; b < last; b++) {
let count = atomicLoad(&counts[block_count(b, page)]);
atomicStore(&counts[block_count(b, page)], start);
start += count;
}
if lane == 255u {
// A region has room for every record of its page; were one short,
// its draws past it go.
let total = min(partial[255], region(page).y);
atomicStore(&counts[STATS + page], total);
atomicAdd(&counts[0], partial[255]);
atomicAdd(&counts[1], total);
}
}
var<workgroup> held: array<u32, 64>;
// One workgroup per block of ids: each section's draw goes after those of the
// block's earlier sections in its page.
@compute @workgroup_size(64)
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
let block = group.y * ROW + group.x;
let id = block * BLOCK + lane;
var mine = 0u;
if id < params.ids {
mine = runs[id];
}
held[lane] = mine;
workgroupBarrier();
if mine == 0u {
return;
}
let page = mine - 1u;
var slot = atomicLoad(&counts[block_count(block, page)]);
for (var other = 0u; other < lane; other++) {
if held[other] == mine {
slot += 1u;
}
}
let r = region(page);
if slot >= r.y {
return;
}
// All of its solid quads, from its first vertex, with its origin.
let s = sections[id];
let d = (r.x + slot) * 5u;
draws[d] = s.a.y * 6u;
draws[d + 1u] = 1u;
draws[d + 2u] = 0u;
draws[d + 3u] = s.a.x;
draws[d + 4u] = id;
}
@@ -0,0 +1,764 @@
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
//!
//! Each frame the render thread walked every section phase 1 draws through
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
//! and pushed one draw per run. Here it pushes one record per section instead:
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
//! mesh), the call each of its parts draws in and its camera-relative origin. The
//! GPU finds the same runs and writes, per call (one per pass group and arena
//! page), either the runs' indices into the flat draws' index list
//! (`Out::Indices`, drawn with `draw_indexed_indirect`), or one indirect draw per
//! run (`Out::Draws`, drawn with `multi_draw_indexed_indirect_count`). Calls take
//! room for the most their sections may write; the GPU counts what they wrote.
//!
//! Solid draws write depth and are tested against it, so their order within a
//! call does not change the picture.
use crate::mesher::{Part, Sides};
/// Words per row: first vertex, `Sides::gpu_row`, padding.
const ROW: usize = 24;
/// Records each workgroup of `sides_draws` takes.
const GROUP: u32 = 64;
/// Most workgroups along one dimension of a dispatch.
const GROUP_LIMIT: u32 = 65535;
/// What the GPU writes for a frame's calls.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Out {
/// Indices into the flat draws' list (`flat::Flat`), one indirect draw per call.
Indices,
/// `DrawIndexedIndirectArgs` per run, over the quad index pattern, with the
/// section's instance; per call a count for `multi_draw_indexed_indirect_count`.
Draws,
}
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
fn part_code(part: Part) -> u32 {
match part {
Part::All => 0,
Part::Opaque => 1,
Part::Cutout => 2,
}
}
/// One of the frame's calls: what pass `group` draws of arena `page`, from
/// `first` (an index, or a draw) with room for `count`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ListCall {
pub group: u8,
pub page: u32,
pub first: u32,
pub count: u32,
}
pub(super) struct GpuLists {
out: Out,
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
rows: wgpu::Buffer,
row_capacity: u64,
free_ids: Vec<u32>,
next_id: u32,
/// The frame's sections: id, the call and part of each of its parts, its
/// number of parts plus its instance times 4; then its origin.
records: Vec<[u32; 8]>,
record_buffer: wgpu::Buffer,
record_capacity: u64,
calls: Vec<ListCall>,
/// `calls`' index by page and group in the band of distance the last
/// section listed is in (`u32::MAX`: none yet).
call_of: Vec<u32>,
band: u32,
call_data: Vec<[u32; 5]>,
/// Per call `DrawIndexedIndirectArgs` (`Out::Indices`), or its count of
/// draws first (`Out::Draws`); the GPU adds what it writes to the first word.
call_buffer: wgpu::Buffer,
call_capacity: u64,
view: wgpu::Buffer,
/// `Out::Draws`' draws.
draws: wgpu::Buffer,
draw_capacity: u64,
group: Option<(wgpu::BindGroup, u32)>,
/// The room the frame's calls take (indices, or draws), and the most they may.
room: u64,
cap: u64,
}
fn buffer(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
// COPY_SRC: rows move to a larger buffer; tests read calls and draws back.
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
impl GpuLists {
pub fn new(device: &wgpu::Device, out: Out) -> Self {
let read = wgpu::BufferBindingType::Storage { read_only: true };
let write = wgpu::BufferBindingType::Storage { read_only: false };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Section lists"),
entries: &[
entry(0, read),
entry(1, read),
entry(2, wgpu::BufferBindingType::Uniform),
entry(3, write),
entry(4, write),
],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Section lists"),
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Section lists"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Section lists"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some(match out {
Out::Indices => "flatten_sides",
Out::Draws => "sides_draws",
}),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE;
let indirect = wgpu::BufferUsages::INDIRECT | storage;
Self {
out,
pipeline,
layout,
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
row_capacity: 1,
free_ids: Vec::new(),
next_id: 0,
records: Vec::new(),
record_buffer: buffer(device, "Sections listed", 32, storage),
record_capacity: 1,
calls: Vec::new(),
call_of: Vec::new(),
band: 0,
call_data: Vec::new(),
call_buffer: buffer(device, "Section list calls", 20, indirect),
call_capacity: 1,
view: buffer(
device,
"Section lists' eye",
16,
wgpu::BufferUsages::UNIFORM,
),
draws: buffer(device, "Section list draws", 20, indirect),
draw_capacity: 1,
group: None,
room: 0,
cap: 0,
}
}
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
pub fn add(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
start: u32,
sides: &Sides,
) -> u32 {
let id = self.free_ids.pop().unwrap_or_else(|| {
self.next_id += 1;
self.next_id - 1
});
self.set(device, encoder, uploads, id, start, sides);
id
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
}
/// Mesh `id` now starts at vertex `start` (moved to another page).
pub fn set(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
start: u32,
sides: &Sides,
) {
let need = u64::from(id) + 1;
if need > self.row_capacity {
let capacity = need.next_power_of_two().max(1024);
let rows = buffer(
device,
"Section rows",
capacity * ROW as u64 * 4,
wgpu::BufferUsages::STORAGE,
);
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
encoder.copy_buffer_to_buffer(
&self.rows,
0,
&rows,
0,
self.row_capacity * ROW as u64 * 4,
);
self.rows = rows;
self.row_capacity = capacity;
}
let mut row = [0u32; ROW];
row[0] = start;
row[1..23].copy_from_slice(&sides.gpu_row());
uploads.buffer(
encoder,
&self.rows,
u64::from(id) * ROW as u64 * 4,
bytemuck::cast_slice(&row),
);
}
/// Starts the frame's lists.
pub fn clear(&mut self, device: &wgpu::Device) {
self.records.clear();
self.calls.clear();
self.call_of.fill(u32::MAX);
self.band = 0;
self.room = 0;
self.group = None;
// A quarter of a binding: indices share theirs with the CPU's draws.
let binding = device.limits().max_storage_buffer_binding_size;
self.cap = match self.out {
Out::Indices => binding / 16,
Out::Draws => binding / 20 / 4,
};
}
/// Section `id`, in `page`, at `origin` from the camera and `distance`
/// (squared, as bits) from it, instance `instance` of the frame's list:
/// each of `parts` (pass group, part) draws the quads of the part that face
/// the eye. False, listing nothing, when the frame's lists hold all the room
/// they may: the caller draws it.
///
/// Sections come nearest first. Each band of distance gets calls of its own,
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
/// a call the GPU writes sections in no set order, and cutout quads, which
/// tile GPUs can't hide before shading, then draw mostly front to back.
pub fn list(
&mut self,
id: u32,
page: u32,
distance: u64,
sides: &Sides,
parts: &[(u8, Part)],
origin: [f32; 3],
instance: u32,
) -> bool {
let parts = &parts[..parts.len().min(2)];
let mut counts = [0; 2];
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
*count = match self.out {
Out::Indices => sides.part_quads(part) * 6,
Out::Draws => sides.part_runs(part),
};
}
let room = u64::from(counts[0]) + u64::from(counts[1]);
if self.room + room > self.cap || instance >= 1 << 30 {
return false;
}
self.room += room;
let band = super::page_runs::band(distance) as u32;
if band != self.band {
self.band = band;
self.call_of.fill(u32::MAX);
}
let mut record = [id, 0, 0, parts.len() as u32 | instance << 2, 0, 0, 0, 0];
for (k, &(group, part)) in parts.iter().enumerate() {
let key = page as usize * 2 + usize::from(group.min(1));
if self.call_of.len() <= key {
self.call_of.resize(key + 1, u32::MAX);
}
if self.call_of[key] == u32::MAX {
self.call_of[key] = self.calls.len() as u32;
self.calls.push(ListCall {
group,
page,
first: 0,
count: 0,
});
}
let call = self.call_of[key];
self.calls[call as usize].count += counts[k];
record[1 + k] = call | part_code(part) << 28;
}
for (to, o) in record[4..7].iter_mut().zip(origin) {
*to = o.to_bits();
}
self.records.push(record);
true
}
/// The sections listed this frame.
pub fn listed(&self) -> usize {
self.records.len()
}
/// The frame's calls, with their rooms once laid out (`rooms`).
pub fn calls(&self) -> &[ListCall] {
&self.calls
}
/// Lays the calls' rooms out one after the other from the start: the
/// room they take (indices, or draws).
pub fn rooms(&mut self) -> u64 {
let mut room = 0u64;
for call in &mut self.calls {
call.first = room as u32;
room += u64::from(call.count);
}
room
}
/// Stages the frame's sections, calls and eye (camera-relative; none: every
/// quad faces it), and binds them for `dispatch` writing into `indices`
/// (`Out::Indices`) or into the lists' own draws. After `rooms`.
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
indices: Option<&wgpu::Buffer>,
eye: Option<[f32; 3]>,
) {
self.group = None;
let records = self.records.len() as u64;
if records == 0 {
return;
}
if records > self.record_capacity {
self.record_capacity = records.next_power_of_two();
self.record_buffer = buffer(
device,
"Sections listed",
self.record_capacity * 32,
wgpu::BufferUsages::STORAGE,
);
}
uploads.buffer(
encoder,
&self.record_buffer,
0,
bytemuck::cast_slice(&self.records),
);
self.call_data.clear();
self.call_data
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
let calls = self.call_data.len() as u64;
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
if calls > self.call_capacity {
self.call_capacity = calls.next_power_of_two();
self.call_buffer = buffer(
device,
"Section list calls",
self.call_capacity * 20,
indirect,
);
}
uploads.buffer(
encoder,
&self.call_buffer,
0,
bytemuck::cast_slice(&self.call_data),
);
let room = self
.calls
.last()
.map_or(0, |c| u64::from(c.first + c.count));
if self.out == Out::Draws && room > self.draw_capacity {
self.draw_capacity = room.next_power_of_two();
self.draws = buffer(
device,
"Section list draws",
self.draw_capacity * 20,
indirect,
);
}
let [x, y, z] = eye.unwrap_or_default();
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
let out = match self.out {
Out::Indices => indices.expect("flat draws' index list"),
Out::Draws => &self.draws,
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section lists"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.record_buffer,
offset: 0,
size: wgpu::BufferSize::new(records * 32),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.rows.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.view.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.call_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: out.as_entire_binding(),
},
],
});
self.group = Some((group, records as u32));
}
/// Writes the frame's lists (after `prepare`).
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
let Some((group, records)) = &self.group else {
return;
};
let groups = match self.out {
Out::Indices => *records,
Out::Draws => records.div_ceil(GROUP),
};
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
}
pub fn out(&self) -> Out {
self.out
}
/// Whether `dispatch` writes this frame's lists.
pub fn prepared(&self) -> bool {
self.group.is_some()
}
/// Draws call `k` (`Out::Indices`) with a pipeline, its page's vertices and
/// the index list bound.
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
}
/// Draws call `k` (`Out::Draws`) with a pipeline, its page's vertices, the
/// quad indices and the frame's instances bound.
pub fn multi_draw(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
let call = self.calls[k];
pass.multi_draw_indexed_indirect_count(
&self.draws,
u64::from(call.first) * 20,
&self.call_buffer,
k as u64 * 20,
call.count,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::{Part, Sides};
use wgpu::util::DeviceExt;
struct Section {
sides: Sides,
origin: [f32; 3],
start: u32,
page: u32,
parts: &'static [(u8, Part)],
}
/// Random sorts: some parts empty, grouped or not, split or not, planes in
/// and out of a section, each listed in one of the three ways it can be.
fn sections(count: usize) -> Vec<Section> {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: u32| {
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((seed >> 33) % u64::from(n)) as u32
};
(0..count)
.map(|_| {
let mut ends = [0; 14];
let mut end = 0;
for e in &mut ends {
if next(3) > 0 {
end += next(40);
}
*e = end;
}
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
Section {
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
start: next(10_000) * 4,
page: next(3),
parts: match next(3) {
0 => &[(0, Part::All)],
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
_ => &[(1, Part::All)],
},
}
})
.collect()
}
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let draw = |first, quads| runs.push((first, quads));
match eye {
Some(eye) => {
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
s.sides.visible_from(eye, eye[1], part, draw);
}
None => s.sides.every(part, draw),
}
runs
}
/// Lists `sections` for `out` from each of a few eyes; per eye, the
/// lists, the calls' words and the written words.
fn run(
out: Out,
sections: &[Section],
) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
let (device, queue) = device()?;
let mut results = Vec::new();
for eye in [
Some([3.5f32, 70.25, -12.0]),
Some([-40.0, -3.0, 50.5]),
None,
] {
let mut lists = GpuLists::new(&device, out);
lists.clear(&device);
let mut rows = vec![0u32; sections.len() * ROW];
for (id, s) in sections.iter().enumerate() {
rows[id * ROW] = s.start;
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
// Nearest first, over several bands.
let distance = (id as f64 * 2.0).powi(2).to_bits();
let instance = id as u32 + 7;
assert!(lists.list(
id as u32, s.page, distance, &s.sides, s.parts, s.origin, instance
));
}
let room = lists.rooms();
let init = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
lists.rows = init(bytemuck::cast_slice(&rows), storage);
let words = match out {
Out::Indices => room,
Out::Draws => room * 5,
};
let indices = init(&vec![0xffu8; words as usize * 4], storage);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
lists.prepare(&device, &mut encoder, &mut uploads, Some(&indices), eye);
uploads.finish(&encoder);
{
let mut pass = encoder.begin_compute_pass(&Default::default());
lists.dispatch(&mut pass);
}
queue.submit([encoder.finish()]);
let written = match out {
Out::Indices => &indices,
Out::Draws => &lists.draws,
};
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: words * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
};
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
let words = read_back(written, words);
results.push((eye, lists, calls, words));
}
Some(results)
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return None;
};
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
if device.is_none() {
eprintln!("no GPU device: skipped");
}
device
}
/// The call section `i`'s `k`th part draws in, as listed.
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
}
/// Each band of distance and page gets a pass's calls of its own, in the
/// order sections come (nearest first), and sections of one share them.
#[test]
fn calls_go_by_band_then_page_nearest_first() {
let Some((device, _)) = device() else {
return;
};
let mut lists = GpuLists::new(&device, Out::Indices);
lists.clear(&device);
let sides = Sides::unsorted(4);
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
let distance = f64::powi(blocks, 2).to_bits();
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3], 0));
}
let calls: Vec<_> = lists
.calls()
.iter()
.map(|c| (c.group, c.page, c.count))
.collect();
assert_eq!(
calls,
[
(0, 1, 48),
(1, 1, 48),
(0, 0, 24),
(1, 0, 24),
(0, 1, 24),
(1, 1, 24),
(0, 0, 24),
(1, 0, 24)
]
);
}
/// `flatten_sides` writes, in each call's room, the indices of exactly the
/// runs `Sides::visible_from` finds for each listed section's parts (in
/// some order: workgroups take room as they come), and counts them.
#[test]
fn flatten_sides_writes_the_runs_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(Out::Indices, &sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
for k in 0..quads * 6 {
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
}
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s index count, eye {eye:?}"
);
assert_eq!(calls[k * 5 + 2], call.first);
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
}
}
}
/// `sides_draws` writes, in each call's room, one draw per run the CPU finds
/// (as the CPU lists them: the run's indices over the quad pattern, from its
/// first vertex, with the section's instance), and counts them; the room
/// holds them all.
#[test]
fn sides_draws_writes_a_draw_per_run_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(Out::Draws, &sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
expected[call].push([quads * 6, 1, 0, s.start + first * 4, i as u32 + 7]);
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s draw count, eye {eye:?}"
);
assert!(count as u32 <= call.count, "call {k} wrote past its room");
let first = call.first as usize * 5;
let mut got: Vec<[u32; 5]> = out[first..first + count * 5]
.chunks(5)
.map(|d| d.try_into().unwrap())
.collect();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s draws, eye {eye:?}");
}
}
}
}
@@ -0,0 +1,216 @@
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
// listed, the runs of its quads that face the eye, found as
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
// each of its parts draws in (one per pass group and arena page) after taking
// room there: their indices (`flatten_sides`, for the flat draws' index list,
// as `flatten_seen` does for occlusion culling's phase 2), or one indirect draw
// per run (`sides_draws`, for `multi_draw_indexed_indirect_count`). Solid draws
// write depth and are tested against it, so their order within a call does not
// change the picture.
struct View {
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
// without, every bucket is drawn (`Sides::every`).
eye: vec3<f32>,
has_eye: f32,
}
// Per section listed: its row's id; for each of its parts (up to two) the call
// it draws in, with the part (`PART_*`) in the top bits; its number of parts
// plus 4 times its instance. Then its origin relative to the camera.
struct Record {
words: vec4<u32>,
origin: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> records: array<Record>;
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
// (14 ends, 7 planes' bits, flags).
@group(0) @binding(1) var<storage, read> rows: array<u32>;
@group(0) @binding(2) var<uniform> view: View;
// Per call five words: its count (indices, or draws), which starts at 0, then
// for `flatten_sides` the rest of its `DrawIndexedIndirectArgs`; word 2 is
// where its room starts.
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
// Indices (`flatten_sides`) or `DrawIndexedIndirectArgs` (`sides_draws`).
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
const ROW: u32 = 24u;
const LANES: u32 = 64u;
const PART_ALL: u32 = 0u;
const PART_OPAQUE: u32 = 1u;
const PART_CUTOUT: u32 = 2u;
const CALL_MASK: u32 = 0x0fffffffu;
// `sides::MARGIN`.
const MARGIN: f32 = 0.0625;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
fn plane(row: u32, bucket: u32) -> f32 {
return bitcast<f32>(rows[row + 15u + bucket]);
}
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
var faces = true;
if view.has_eye != 0.0 {
switch bucket {
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
default: {}
}
}
return faces;
}
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
// out and there are 14.
struct Runs {
n: u32,
quads: u32,
runs: array<vec2<u32>, 7>,
}
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
// the section): empty parts do not break a run.
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
var r: Runs;
let grouped = (rows[row + 22u] & 1u) != 0u;
let split = (rows[row + 22u] & 2u) != 0u;
var start = 0u;
var open = false;
var first = 0u;
for (var n = 0u; n < 14u; n++) {
let end = rows[row + 1u + n];
if end == start {
continue;
}
var bucket: u32;
var cutout: bool;
if grouped {
bucket = n % 7u;
cutout = n >= 7u;
} else {
bucket = n / 2u;
cutout = n % 2u == 1u;
}
let wanted = part == PART_ALL || !split
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
let take = wanted && seen(row, bucket, eye);
if take && !open {
open = true;
first = start;
} else if !take && open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
open = false;
}
start = end;
}
if open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
}
return r;
}
fn record_index(group: vec3<u32>) -> u32 {
return group.y * 65535u + group.x;
}
// The eye relative to `record`'s section.
fn eye_of(record: Record) -> vec3<f32> {
return view.eye - record.origin.xyz;
}
fn part_word(record: Record, p: u32) -> u32 {
return select(record.words.z, record.words.y, p == 0u);
}
// Lane 0's runs per part, and where each part's indices start.
var<workgroup> found: array<Runs, 2>;
var<workgroup> at: array<u32, 2>;
// One workgroup per record: lane 0 finds the runs and takes room for their
// indices, then every lane writes them.
@compute @workgroup_size(64)
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group);
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w & 3u;
if lane == 0u {
let eye = eye_of(record);
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
let call = (word & CALL_MASK) * 5u;
var begin = 0u;
if runs.quads > 0u {
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
}
found[p] = runs;
at[p] = begin;
}
}
workgroupBarrier();
let base = rows[row];
for (var p = 0u; p < parts; p++) {
var to = at[p];
for (var k = 0u; k < found[p].n; k++) {
let run = found[p].runs[k];
let first = base + run.x * 4u;
let count = run.y * 6u;
for (var i = lane; i < count; i += LANES) {
out[to + i] = first + quad(i);
}
to += count;
}
}
}
// One record per invocation: a draw per run, as the CPU lists them
// (`[quads * 6, 1, 0, first vertex, instance]` over the quad index pattern).
@compute @workgroup_size(64)
fn sides_draws(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group) * LANES + lane;
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w & 3u;
let instance = record.words.w >> 2u;
let eye = eye_of(record);
let base = rows[row];
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
if runs.n == 0u {
continue;
}
let call = (word & CALL_MASK) * 5u;
var to = (atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.n)) * 5u;
for (var k = 0u; k < runs.n; k++) {
let run = runs.runs[k];
out[to] = run.y * 6u;
out[to + 1u] = 1u;
out[to + 2u] = 0u;
out[to + 3u] = base + run.x * 4u;
out[to + 4u] = instance;
to += 5u;
}
}
}
@@ -645,7 +645,6 @@ mod tests {
include_str!("../hdr.wgsl"),
include_str!("../terrain.wgsl"),
include_str!("../hdr_stats.wgsl"),
include_str!("stars.wgsl"),
&entity,
] {
let module = naga::front::wgsl::parse_str(source).unwrap();
+4 -24
View File
@@ -7,19 +7,13 @@ use crate::{
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
use std::{
collections::VecDeque,
time::{Duration, Instant},
};
use std::time::{Duration, Instant};
/// Finished tile meshes not uploaded yet, the tiles they replace (removed
/// once every mesh is in) and the columns they leave out.
#[derive(Default)]
pub(super) struct Pending {
meshes: FxHashMap<TileKey, SectionMesh>,
/// The order they go up in: as handed over (`Lod::update`: finer tiles
/// first, so no tile clipped around a finer one is drawn before it).
order: VecDeque<TileKey>,
removed: FxHashSet<TileKey>,
cutout: Option<FxHashSet<(i32, i32)>>,
}
@@ -36,9 +30,7 @@ impl Pending {
) {
for (key, mesh) in meshes {
self.removed.remove(&key);
if self.meshes.insert(key, mesh).is_none() {
self.order.push_back(key);
}
self.meshes.insert(key, mesh);
}
for key in removed {
self.meshes.remove(&key);
@@ -338,10 +330,7 @@ impl Renderer {
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
// Keys meshed again keep their place; removed ones are skipped.
let next = std::iter::from_fn(|| pending.order.pop_front())
.find(|key| pending.meshes.contains_key(key));
let Some(key) = next else {
let Some(&key) = pending.meshes.keys().next() else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
@@ -509,16 +498,7 @@ mod tests {
let mut removed: Vec<_> = pending.removed.iter().map(|k| k.x).collect();
removed.sort_unstable();
assert_eq!((meshes, removed), (vec![1, 3], vec![2, 4]));
assert!(pending.cutout.as_ref().is_some_and(|c| c.contains(&(0, 0))));
// They go up in the order handed over, each once.
pending.add(vec![(key(1), SectionMesh::default())], vec![], None);
let order: Vec<_> = pending
.order
.iter()
.filter(|k| pending.meshes.contains_key(k))
.map(|k| k.x)
.collect();
assert_eq!(order, [1, 3]);
assert!(pending.cutout.is_some_and(|c| c.contains(&(0, 0))));
}
#[test]
@@ -23,8 +23,6 @@ pub struct NativePack {
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// `[[lights]]`, for the colour of held and dropped items' light.
pub light_colors: Vec<horizon_shader_api::manifest::LightColor>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -138,7 +136,6 @@ impl NativePack {
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
light_colors: report.manifest.lights.clone(),
frame,
sky,
material,
@@ -11,10 +11,8 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -42,8 +40,8 @@ pub fn world_drawn_hooked() -> bool {
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand ([`HandDrawn`]),
/// the HUD and the screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
@@ -72,67 +70,10 @@ pub struct WorldDrawn<'a> {
/// ([`RenderNeeds::entity_spans`]) or a shader pack draws them.
pub entities: &'a [Vertex],
pub spans: &'a [EntitySpan],
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
pub block_sprites: BlockSpriteArrays<'a>,
}
/// The block sprite arrays ([`WorldDrawn::block_sprites`]): each texture a whole square layer,
/// its UVs 0 to 1 across it, as the world uses them (gamma-encoded, animated layers at the frame
/// shown).
#[non_exhaustive]
pub struct BlockSpriteArrays<'a> {
/// The arrays as `D2Array` views, terrain.wgsl's eight (`sprites`, `sprites_1`...): a 1-layer
/// stand-in past the ones in use, each with the mip levels the Mipmap Levels option leaves.
pub views: &'a [wgpu::TextureView],
/// Layers per array on this device (2048 on most; fewer where the GPU holds fewer, and
/// OpenGL pads each array past it): a sprite's `array` and `layer` are cut by it.
pub split: u32,
/// A layer's width and height in texels.
pub size: u32,
/// Nearest, repeating, the nearest mip level: texels drawn sharp.
pub nearest: &'a wgpu::Sampler,
/// The terrain's own (linear, clamped, anisotropic under that Texture Filtering).
pub filtered: &'a wgpu::Sampler,
}
/// What a mod can draw with once the first-person hand is drawn ([`native_hand_drawn`]): after
/// the world and the hand, before entity outlines, post effects, screen effects and the HUD.
/// `color` holds the frame's picture with the hand in it (copy it out before reading it).
#[non_exhaustive]
pub struct HandDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The hand and held item just drawn, camera-relative quads of 4 vertices: empty when no
/// hand drew (third person, F1, a sleeping or spectating player) or a shader pack drew it.
pub hand: &'a [Vertex],
/// Camera-relative world to clip space for the hand: its own field of view
/// (`hand_fov_degrees`) and the view bob, without TAA's jitter (the hand has none). Its
/// depth is the hand's own: the hand's pass clears depth and keeps none.
pub view_proj: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The shared atlas the hand's UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices).
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
@@ -153,13 +94,6 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
@@ -190,189 +124,6 @@ mod tests {
}
}
/// A mod samples `BlockSpriteArrays` as the renderer hands them over: a layer of the second
/// array through its view, texels sharp and repeating with the nearest sampler.
#[test]
fn a_pass_samples_a_sprite_layer_sharp() {
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
return;
};
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
// two arrays of two 2 by 2 layers; array 1's layer 1 red, green / blue, white
let layers = super::super::array_layers(2, gl);
let quad = [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[255; 4],
];
let textures: Vec<wgpu::Texture> = (0..2)
.map(|array| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: layers,
},
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: &[],
});
let mut texels = vec![0u8; 16 * layers as usize];
if array == 1 {
texels[16..32].copy_from_slice(quad.as_flattened());
}
queue.write_texture(
texture.as_image_copy(),
&texels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(8),
rows_per_image: Some(2),
},
texture.size(),
);
texture
})
.collect();
let unused = textures[0].create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let views = super::super::sprite_views(&textures, &unused, Some(1));
let nearest = device.create_sampler(&super::super::SPRITE_NEAREST);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var sprites: texture_2d_array<f32>;
@group(0) @binding(1) var sprites_1: texture_2d_array<f32>;
@group(0) @binding(2) var nearest: sampler;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
// inside each texel, and past the edge (repeating); the last reads array 0
let uv = array(vec2(1.3, 0.2), vec2(0.7, 0.45), vec2(-0.8, 0.9), vec2(0.6, 0.6));
let x = u32(at.x);
if x == 4u {
return textureSampleLevel(sprites, nearest, vec2(0.3), 1, 0.0);
}
return textureSampleLevel(sprites_1, nearest, uv[x], 1, 0.0);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&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: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&views[0]),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&views[1]),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
],
});
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 5,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = target.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &out,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: None,
},
},
target.size(),
);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = out.slice(..).get_mapped_range().unwrap()[..20].to_vec();
let mut expected = quad.as_flattened().to_vec();
expected.extend([0, 0, 0, 0]);
assert_eq!(texels, expected);
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
@@ -0,0 +1,36 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
@@ -13,7 +13,6 @@
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
use glam::DVec3;
use std::collections::{HashMap, VecDeque};
@@ -590,19 +589,6 @@ impl Occlusion {
}
}
}
/// `remove_column` for each of `gone`. When they are every column it holds (a
/// teleport drops the whole view), the old graph says nothing about what arrives
/// next: it starts over as `clear` does instead of seeing through each removed
/// section one edit at a time (some 90 000 at render distance 32).
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) {
if self.columns.map.keys().all(|column| gone.contains(column)) {
self.clear();
return;
}
for &(x, z) in gone {
self.remove_column(x, z);
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
self.edits.clear();
@@ -1327,46 +1313,6 @@ mod tests {
println!("search_cost: best {best:.2} ms over 12, {reached} reached");
}
/// Removed columns see through every face: the graph is the one of a world that
/// never had them, whether some columns go or all of them (a teleport).
#[test]
fn removed_columns_leave_the_graph_of_a_world_without_them() {
fn without(o: &Occlusion, gone: &FxHashSet<(i32, i32)>) -> Occlusion {
let mut fresh = Occlusion::new(0, 6);
for (&(x, z), column) in &o.columns.map {
if !gone.contains(&(x, z)) {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
}
fresh
}
let camera = at((0, 2, 0));
let mut o = Occlusion::new(0, 6);
random_world(&mut o, 0x51ed_2701, 900, 6);
o.settle(camera, 6);
let some: FxHashSet<(i32, i32)> = (-6..=6)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
let mut expected = without(&o, &some);
o.remove_columns(&some);
o.settle(camera, 6);
expected.settle(camera, 6);
let graph = |o: &Occlusion| o.graph.as_ref().expect("searched").directions.clone();
assert_eq!(graph(&o), graph(&expected));
let all: FxHashSet<(i32, i32)> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
assert!(o.contains((40, 3, 40)), "no graph from the old view culls");
random_world(&mut o, 0x2545_f491, 900, 6);
let mut arrived = Occlusion::new(0, 6);
random_world(&mut arrived, 0x2545_f491, 900, 6);
o.settle(camera, 6);
arrived.settle(camera, 6);
assert_eq!(graph(&o), graph(&arrived));
}
/// The worker's graph is the one the render thread would compute.
#[test]
fn the_worker_matches_a_search_on_the_render_thread() {
@@ -12,8 +12,6 @@ pub(super) struct PackAtlas {
pub sprite: u32,
/// Layers per sprite array.
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
}
impl PackAtlas {
@@ -59,7 +57,6 @@ impl PackAtlas {
columns,
sprite,
split,
separate_ao: false,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
@@ -136,10 +133,6 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
vec3 hz_e1 = hz_p1 - hz_p0;\n \
vec3 hz_e2 = hz_p2 - hz_p0;\n \
vec3 hz_n = cross(hz_e1, hz_e2);\n \
@@ -168,9 +161,8 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
// and the arena page's vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
@@ -187,24 +179,17 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
"hz_v_color.a"
} else {
"1.0"
};
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
hz_mid = vec2(0.5);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -213,19 +198,15 @@ impl PackAtlas {
if flat {
body.push_str(FACE);
}
declarations.push_str(
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
);
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
let entity = if flat {
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
} else {
"vec4(0.0)"
};
@@ -234,7 +215,7 @@ impl PackAtlas {
("at_midBlock", mid_block),
(
"mc_midTexCoord",
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
"vec4((hz_cell + 0.5) * hz_scale, 0.0, 1.0)",
),
("at_tangent", "hz_tangent"),
("vaPosition", "hz_Vertex.xyz"),
@@ -256,10 +237,6 @@ impl PackAtlas {
}
}
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
#[cfg(test)]
mod tests {
use super::*;
@@ -336,50 +313,4 @@ mod tests {
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
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,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let mut atlas = PackAtlas {
texture,
view,
columns: 1,
sprite: 16,
split: 1,
separate_ao: false,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
inputs_default
.body
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
);
atlas.separate_ao = true;
let inputs_separate = atlas.terrain_inputs(false);
assert!(
inputs_separate
.body
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
);
}
}
@@ -1,353 +0,0 @@
//! Original GPU probes of the production terrain inputs, without a resource or shader pack.
use super::PackAtlas;
use crate::mesher::{TerrainVertex, UV_STEPS};
use wgpu::util::DeviceExt;
#[test]
fn mid_tex_coord_follows_each_quads_mapped_corners() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
eprintln!("midpoint probe adapter: {:?}", adapter.get_info());
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 5,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 5, 5, 16).unwrap();
queue.submit([encoder.finish()]);
// Each row uses a different atlas cell. Expected atlas coordinates are independent
// numbers, including the existing clamp of a model UV beyond the sprite's edge.
let cases = [
(
"whole",
[[0., 0.], [1., 0.], [1., 1.], [0., 1.]],
[1. / 6., 0.25],
[1. / 6., 0.25],
[[0., 0.], [1. / 3., 0.], [1. / 3., 0.5], [0., 0.5]],
),
(
"half-height",
[[0., 0.], [1., 0.], [1., 0.5], [0., 0.5]],
[0.5, 0.125],
[0.5, 0.25],
[
[1. / 3., 0.],
[2. / 3., 0.],
[2. / 3., 0.25],
[1. / 3., 0.25],
],
),
(
"rotated partial",
[[0.25, 0.75], [0.25, 0.25], [0.5, 0.25], [0.5, 0.75]],
[19. / 24., 0.25],
[5. / 6., 0.25],
[
[0.75, 0.375],
[0.75, 0.125],
[5. / 6., 0.125],
[5. / 6., 0.375],
],
),
(
"clamped",
[[0., 0.], [1.5, 0.], [1.5, 0.5], [0., 0.5]],
[1. / 6., 0.625],
[1. / 6., 0.75],
[[0., 0.5], [1. / 3., 0.5], [1. / 3., 0.75], [0., 0.75]],
),
(
"fourth corner",
[[0., 0.], [1., 0.], [1., 0.5], [0., 1.]],
[0.5, 0.6875],
[0.5, 0.75],
[
[1. / 3., 0.5],
[2. / 3., 0.5],
[2. / 3., 0.75],
[1. / 3., 1.],
],
),
];
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let vertices: Vec<_> = cases
.iter()
.enumerate()
.flat_map(|(layer, (_, uv, ..))| {
(0..4).map(move |k| {
TerrainVertex::new(positions[k], uv[k], layer as u16 | 0x8000, u32::MAX, 0)
})
})
.collect();
let mut failures = Vec::new();
// Positive origins encode fade bits beside the power-of-two scale. Negative origins
// use the older fractional scale encoding. Both must match the mapped vertex UVs.
for origin in [
f32::from_bits((1. / UV_STEPS).to_bits() | 0x12345),
-3. - 1. / UV_STEPS,
] {
for flat in [true, false] {
for shadow in [false, true] {
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [flat, shadow]);
for (quad, (name, _, midpoint, fallback, mapped)) in cases.iter().enumerate() {
let centre = if flat { midpoint } else { fallback };
for (corner, uv) in mapped.iter().enumerate() {
let actual = pixels[4 * quad + corner];
let expected = [centre[0], centre[1], uv[0], uv[1]];
if actual
.iter()
.zip(expected)
.any(|(a, b)| !a.is_finite() || (a - b).abs() > 1e-6)
{
failures.push(format!("{name} corner {corner}, flat={flat} shadow={shadow} origin={origin}: {actual:?} != {expected:?}"));
}
}
}
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
[flat, shadow]: [bool; 2],
) -> Vec<[f32; 4]> {
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = vec4(mc_midTexCoord, gl_MultiTexCoord0.xy);\n\
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
let fragment = pp(
"varying vec4 probe;\nlayout(location = 0) out uvec4 midpoint_bits;\n\
void main() { midpoint_bits = floatBitsToUint(probe); }"
.into(),
);
let inputs = atlas.terrain_inputs(flat);
let program = if shadow {
horizon_shaders::translate_shadow(&vertex, &fragment, &inputs)
} else {
horizon_shaders::translate_gbuffers(
&vertex,
&fragment,
horizon_shaders::Target::Buffers,
&inputs,
)
}
.unwrap();
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries = [
vec![wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
vec![storage(0), storage(1)],
vec![],
vec![storage(0), storage(1)],
];
let layouts: Vec<_> = entries
.iter()
.map(|entries| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries,
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let vertex_layouts = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &crate::gfx::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![5 => Float32x4],
}),
];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Uint,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let buffer = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let uniform = buffer(
&vec![0; program.interface.uniform_size.max(16) as usize],
wgpu::BufferUsages::UNIFORM,
);
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let origin = buffer(
bytemuck::bytes_of(&[0., 0., 0., scale]),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
let data = buffer(
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let bindings = [
vec![(0, &uniform)],
vec![(0, &slots), (1, &origin)],
vec![],
vec![(0, &ids), (1, &data)],
];
let groups: Vec<_> = bindings
.iter()
.zip(&layouts)
.map(|(buffers, layout)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.iter()
.map(|&(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: vertices.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Uint,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = output.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
for (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(0..vertices.len() as u32, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
bytemuck::cast_slice::<u8, [f32; 4]>(&mapped).to_vec()
}
@@ -191,28 +191,21 @@ impl CenterDepth {
slot.state.store(COPIED, Ordering::Release);
}
/// The readback of what the frame copied, to start by calling it right after the frame's
/// submission: on the submit thread when the frame goes there, as culling's readback.
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
let copied: Vec<_> = self
.slots
.iter()
.filter(|s| s.state.load(Ordering::Acquire) == COPIED)
.map(|s| {
s.state.store(PENDING, Ordering::Release);
(s.buffer.clone(), s.state.clone())
})
.collect();
(!copied.is_empty()).then_some(move || {
for (buffer, state) in copied {
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
}
})
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
}
/// The newest depth read back so far, `None` before the first arrives.
@@ -249,70 +242,6 @@ mod tests {
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn clear(encoder: &mut wgpu::CommandEncoder, view: &wgpu::TextureView, depth: f32) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
/// 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]
fn the_centre_depth_follows_frames_submitted_on_the_submit_thread() {
let Some((device, queue)) = test_device() else {
return;
};
let queue = super::super::submit::Queue::new(queue, true);
if !queue.threaded() {
return;
}
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
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());
let mut center = CenterDepth::new(&device);
let mut latest = None;
for frame in 1..=4 * SLOTS {
let value = frame as f32 / 16.0;
let mut encoder = device.create_command_encoder(&Default::default());
clear(&mut encoder, &view, value);
center.copy(&device, &mut encoder, &depth);
let after = center
.submitted()
.map(|f| Box::new(f) as super::super::submit::AfterSubmit);
let job = super::super::submit::Job {
encoder,
surface: None,
after,
};
assert!(queue.submit_later(job).is_ok());
queue.wait();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
latest = center.collect(&device);
assert_eq!(latest, Some(value), "frame {frame}");
}
assert_eq!(latest, Some(0.75));
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
@@ -351,7 +280,7 @@ mod tests {
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted().expect("a copy to read back")();
center.submitted();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
@@ -359,7 +288,7 @@ mod tests {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted().expect("a free slot")();
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
@@ -104,32 +104,6 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
}
/// What a shadow program with a geometry stage reads of entities and block entities: the
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
let words = std::mem::size_of::<Vertex>() / 4;
let pull = format!(
" uint hz_pw = hz_vertex_index * {words}u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_entity_vertices[hz_pw], hz_entity_vertices[hz_pw + 1u], hz_entity_vertices[hz_pw + 2u]));\n \
hz_v_uv = uintBitsToFloat(uvec2(hz_entity_vertices[hz_pw + 3u], hz_entity_vertices[hz_pw + 4u]));\n \
hz_v_overlay = hz_entity_vertices[hz_pw + 5u];\n \
hz_v_color = unpackUnorm4x8(hz_entity_vertices[hz_pw + 6u]);\n \
uint hz_pl = hz_entity_vertices[hz_pw + 7u];\n \
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
);
horizon_shaders::GeometryInputs {
inputs: inputs(),
pull,
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
index:
"((@) / 6u * 4u + ((@) % 6u < 3u ? (@) % 6u : ((@) % 6u == 3u ? 0u : (@) % 6u - 2u)))"
.to_owned(),
}
}
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
@@ -323,10 +297,6 @@ impl PackEntities {
(buffer, group)
}
pub fn skin_target(&self) -> &wgpu::Buffer {
&self.vertices
}
pub fn vertices(&self) -> wgpu::BufferSlice<'_> {
self.vertices.slice(..)
}
@@ -352,32 +322,11 @@ impl PackEntities {
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
let program = program_of(&span).expect("tagged skin stage");
let pack = drawn[program] && !span.hidden;
let casts = shadow.cast(&span, program);
if !pack && !casts {
continue;
}
self.push_draw(
span,
range.start,
range.len() as u32,
program,
pack,
casts,
overlay,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
@@ -398,24 +347,35 @@ impl PackEntities {
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
self.push_draw(
span,
posed + start,
run.len() as u32,
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(run[0].layer & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: run.len() as u32 / 4,
program,
pack,
casts,
run[0].layer & 0xffff,
);
shadow: casts,
slot: self.draws.len() as u32,
});
}
if self.draws.is_empty() && posed == 0 {
if self.draws.is_empty() {
return;
}
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
self.draws.len() as u64,
);
let (need_vertices, need_slots) = (self.scratch.len() as u64, self.draws.len() as u64);
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
@@ -431,52 +391,8 @@ impl PackEntities {
self.quads = quads.next_power_of_two();
self.indices = quad_indices(device, self.quads);
}
if !self.scratch.is_empty() {
queue.write_buffer(
&self.vertices,
u64::from(posed) * vertex_size,
bytemuck::cast_slice(&self.scratch),
);
}
if !self.uniforms.is_empty() {
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
#[allow(clippy::too_many_arguments)]
fn push_draw(
&mut self,
span: EntitySpan,
start: u32,
vertices: u32,
program: usize,
pack: bool,
casts: bool,
overlay: u32,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
};
let mut bytes = [0u8; SLOT as usize];
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
// The overlay: the layer's low 16 bits (HDR output's tier rides in the high ones).
for (i, c) in entity_color(overlay & 0xffff).iter().enumerate() {
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
self.uniforms
.resize(self.uniforms.len() + (self.stride - SLOT) as usize, 0);
self.draws.push(Draw {
base: start,
quads: vertices / 4,
program,
pack,
shadow: casts,
slot: self.draws.len() as u32,
});
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.scratch));
queue.write_buffer(&self.slots, 0, &self.uniforms);
}
}
@@ -682,258 +598,3 @@ mod crack_projection_tests {
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
/// Intersects shader identities with overlay runs, without reading posed vertices back.
fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32>, u32)> {
let mut out: Vec<(EntitySpan, Range<u32>, u32)> = Vec::new();
let mut span_index = 0;
for job in &skin.jobs {
let mut at = job.dst;
let end = at + job.count;
while at < end {
while span_index + 1 < skin.spans.len() && skin.spans[span_index + 1].start <= at {
span_index += 1;
}
let Some(&span) = skin.spans.get(span_index) else {
break;
};
let stop = end.min(skin.spans.get(span_index + 1).map_or(end, |s| s.start));
if stop <= at {
break;
}
if let Some((old, range, overlay)) = out.last_mut()
&& (EntitySpan { start: 0, ..*old }) == (EntitySpan { start: 0, ..span })
&& *overlay == job.overlay
&& range.end == at
{
range.end = stop;
} else {
out.push((span, at..stop, job.overlay));
}
at = stop;
}
}
out
}
#[cfg(test)]
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
vertices: 24,
..Default::default()
};
skin.spans = vec![
EntitySpan {
start: 0,
stage: render_stage("ENTITIES"),
id: 40,
..Default::default()
},
EntitySpan {
start: 12,
stage: render_stage("ENTITIES"),
id: 41,
hidden: true,
..Default::default()
},
];
skin.jobs = vec![
SkinJob {
dst: 0,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 4,
count: 4,
overlay: 1,
..Default::default()
},
SkinJob {
dst: 8,
count: 12,
overlay: 5,
..Default::default()
},
SkinJob {
dst: 20,
count: 4,
overlay: 5,
..Default::default()
},
];
let runs = skin_runs(&skin);
assert_eq!(
runs.iter()
.map(|(s, r, o)| (s.id, r.clone(), *o, s.hidden))
.collect::<Vec<_>>(),
[
(40, 0..8, 1, false),
(40, 8..12, 5, false),
(41, 12..24, 5, true)
]
);
assert_eq!(entity_color(runs[0].2)[3], 0.30196077);
assert!(
Casters {
entities: true,
..Default::default()
}
.cast(&runs[2].0, ENTITY)
);
skin.truncate((2, 0, 8));
assert_eq!(skin.vertices, 8);
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
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 = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let entity = |start, id| EntitySpan {
start,
id,
stage: render_stage("ENTITIES"),
..Default::default()
};
let mut skin = SkinFrame {
vertices: 12,
spans: vec![
entity(0, 40),
EntitySpan {
hidden: true,
..entity(4, 41)
},
EntitySpan {
start: 8,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
emissive: true,
..Default::default()
},
],
jobs: [1 | (2 << 16), 5, 0]
.into_iter()
.enumerate()
.map(|(i, overlay)| SkinJob {
dst: i as u32 * 4,
count: 4,
overlay,
..Default::default()
})
.collect(),
..Default::default()
};
let cpu = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
let spans = [entity(4, 50)];
let mut rest = Vec::new();
let shadows = Casters {
entities: true,
blocks: true,
..Default::default()
};
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
Some(&skin),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
assert_eq!(
geometry
.draws
.iter()
.map(|d| (d.base, d.quads, d.pack, d.shadow))
.collect::<Vec<_>>(),
[
(0, 1, true, true),
(4, 1, false, true),
(8, 1, true, true),
(12, 1, true, true)
]
);
let slot = |i: usize| &geometry.uniforms[i * geometry.stride as usize..][..SLOT as usize];
let word = |bytes: &[u8], at| i32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
assert_eq!((word(slot(0), 4), word(slot(0), 8)), (40, 0));
assert_eq!((word(slot(1), 4), word(slot(1), 8)), (41, 0));
assert_eq!((word(slot(2), 4), word(slot(2), 8)), (0, 99));
assert_eq!((word(slot(3), 4), word(slot(3), 8)), (50, 0));
let hurt: &[f32] = bytemuck::cast_slice(&slot(0)[16..32]);
assert_eq!(&hurt[..3], &[1.0, 0.0, 0.0]);
assert!((hurt[3] - 77.0 / 255.0).abs() < 1e-6);
let flash: &[f32] = bytemuck::cast_slice(&slot(1)[16..32]);
assert_eq!(&flash[..3], &[1.0, 1.0, 1.0]);
assert!((flash[3] - 192.0 / 255.0).abs() < 1e-6);
assert_eq!(word(slot(2), 0), render_stage("BLOCK_ENTITIES") as i32);
let mut special = cpu.clone();
special[4..].iter_mut().for_each(|v| v.layer = 2);
geometry.prepare(
&device,
&queue,
&layout.layout,
&special,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
shadows,
&mut rest,
None,
);
assert!(geometry.draws.is_empty());
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&special)
);
// Culling can retain hidden jobs for a pack whose entity caster is absent.
// The compute output must still fit, even though no visible/shadow draw uses it.
skin.vertices = 5000;
skin.jobs.truncate(1);
skin.jobs[0].count = 5000;
skin.spans = vec![EntitySpan {
hidden: true,
..entity(0, 60)
}];
geometry.prepare(
&device,
&queue,
&layout.layout,
&[],
&[],
[false; PROGRAMS],
([0.; 4], [1; 2]),
Casters::default(),
&mut rest,
Some(&skin),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
@@ -269,121 +269,20 @@ pub(super) fn clear(
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
const ZEROS: u64 = 4 << 20;
/// What an image no `image.*` gives binds. Iris 1.11.7 runs the program, and its loads read
/// zero, also after it stored into the image (observed with a probe pack). Here it is one texel
/// of the declared format, cleared before every pass that binds it; loads and stores outside
/// that texel are out of bounds (with the same probe pack on Vulkan, the loads read zero).
pub(super) struct StandIn {
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
pub(super) view: wgpu::TextureView,
zeros: wgpu::Buffer,
}
impl StandIn {
/// Zero again, before a pass that binds it.
pub(super) fn clear(&self, encoder: &mut wgpu::CommandEncoder) {
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &self.zeros,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: None,
rows_per_image: None,
},
},
self.view.texture().as_image_copy(),
self.view.texture().size(),
);
}
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
wanted: Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>,
) -> Vec<StandIn> {
wanted
.into_iter()
.map(|(format, dimension)| {
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
format,
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
);
let view = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack image stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: if dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| if atomic {
wgpu::TextureUsages::STORAGE_ATOMIC
} else {
wgpu::TextureUsages::empty()
},
view_formats: &[],
})
.create_view(&Default::default());
// New buffers start zeroed; one texel is at most 16 bytes.
let zeros = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image stand-in zeros"),
size: 16,
usage: wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
StandIn {
format,
dimension,
view,
zeros,
}
})
.collect()
}
/// The stand-in of this format and dimension.
pub(super) fn stand_in(
stand_ins: &[StandIn],
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
) -> Option<&StandIn> {
stand_ins
.iter()
.find(|s| s.format == format && s.dimension == dimension)
}
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
minimums: BTreeMap<u32, u64>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
/// The layout of `objects`, bound in `stages`.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
minimums: BTreeMap<u32, u64>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
@@ -406,7 +305,6 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -432,8 +330,7 @@ impl Buffers {
.objects
.iter()
.map(|(binding, o)| {
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
let wanted = o.size(size).next_multiple_of(4);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
@@ -471,13 +368,6 @@ impl Buffers {
}
}
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
@@ -568,27 +458,3 @@ impl Dump {
self.pending = Some((buffer, texel, dims, row));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -1,65 +0,0 @@
//! The block outline drawn by a shader pack's `gbuffers_line` (or `gbuffers_basic`, as in Iris),
//! so packs that style it (Complementary Reimagined, Super Duper Vanilla) draw it their way, and
//! packs that render the world into part of their buffers (SEUS PTGI HRR) move it with the rest.
//! The edges are the outline buffer Horizon draws itself, one instance per edge, widened to a
//! strip as `outline_clip` in terrain.wgsl does.
/// What `gbuffers_line` reads from an outline edge: its camera-relative ends, ARGB colour and
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
/// vertices per edge: pack pipelines draw triangle lists); the
/// width is applied in screen space after the pack's transform.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations =
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
int hz_l_c;\n"
.to_owned();
// Pack pipelines draw triangle lists: six vertices per edge, the strip's corners
// 0 1 2 and 2 1 3.
let body = " int hz_l_v = int(gl_VertexIndex);\n \
hz_l_c = hz_l_v < 3 ? hz_l_v : hz_l_v == 3 ? 2 : hz_l_v == 4 ? 1 : 3;\n \
hz_Vertex = vec4(hz_l_c >= 2 ? hz_l_end : hz_l_start, 1.0);\n \
hz_Color = hz_l_argb.zyxw;\n \
hz_Normal = normalize(hz_l_end - hz_l_start);\n \
hz_MultiTexCoord0 = vec4(0.0, 0.0, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n"
.to_owned();
// ARGB in a little-endian u32 reads as B, G, R, A.
let attributes = [
("vaPosition", "hz_Vertex.xyz"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
];
let after = " {\n \
vec4 hz_l_a = hz_ModelViewProjectionMatrix * vec4(hz_l_start, 1.0);\n \
vec4 hz_l_b = hz_ModelViewProjectionMatrix * vec4(hz_l_end, 1.0);\n \
vec2 hz_l_d = (hz_l_b.xy / hz_l_b.w - hz_l_a.xy / hz_l_a.w) * hz_target.xy;\n \
hz_l_d = dot(hz_l_d, hz_l_d) > 1e-12 ? normalize(hz_l_d) : vec2(1.0, 0.0);\n \
vec2 hz_l_o = vec2(-hz_l_d.y, hz_l_d.x) * hz_l_width / hz_target.xy;\n \
if (hz_l_o.x < 0.0) hz_l_o = -hz_l_o;\n \
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
.to_owned();
horizon_shaders::GbuffersInputs {
declarations,
body,
attributes: attributes
.iter()
.map(|&(n, v)| (n.to_owned(), v.to_owned()))
.collect(),
after,
alpha_test: None,
}
}
/// The outline edges as the pack's lines program reads them: one 32-byte instance per edge.
pub fn layout() -> wgpu::VertexBufferLayout<'static> {
const ATTRIBUTES: [wgpu::VertexAttribute; 4] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32];
wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &ATTRIBUTES,
}
}
File diff suppressed because it is too large. Load diff
@@ -1,130 +0,0 @@
//! Original regression guard: each HDR post draw fits, so its frame clears must also fit.
use super::*;
fn hdr_pack(sized: bool, format: &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();
};
let mut properties = String::new();
for buffer in 2..7 {
let program = if buffer == 2 {
"composite".to_owned()
} else {
format!("composite{}", buffer - 2)
};
add(&format!("{program}.vsh"), VERTEX);
add(
&format!("{program}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{buffer} */\n/* const int colortex{buffer}Format={format}; */\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
),
);
if sized {
properties.push_str(&format!("size.buffer.colortex{buffer}=0.5 0.5\n"));
}
}
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragColor=texture2D(colortex2,uv); }\n",
);
add("shaders.properties", &properties);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-hdr-clear-budget",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 6, "{:?}", post.notes);
let expected = horizon_shaders::Format {
channels: 4,
bits: if format == "RGBA16F" { 16 } else { 8 },
kind: if format == "RGBA16F" {
horizon_shaders::Kind::Float
} else {
horizon_shaders::Kind::Unorm
},
};
for buffer in 2..7 {
assert_eq!(
post.config.buffers[buffer].format, expected,
"fixture buffer {buffer} must have {format}"
);
}
post
}
fn hdr_clear_case(sized: bool, attachment_limit: u32, byte_budget: u32, format: &str) {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("HDR clear-budget test skipped: no GPU adapter");
return;
};
let mut limits = adapter.limits();
limits.max_color_attachment_bytes_per_sample = limits
.max_color_attachment_bytes_per_sample
.min(byte_budget);
limits.max_color_attachments = limits.max_color_attachments.min(attachment_limit);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: limits,
..Default::default()
}))
.expect("GPU device for HDR clear-budget contract");
eprintln!(
"HDR clear-budget adapter {:?}, requested attachments {} / bytes {}",
adapter.get_info(),
device.limits().max_color_attachments,
device.limits().max_color_attachment_bytes_per_sample
);
let gpu = Fixture {
adapter,
device,
queue,
};
// Five sequential single-output programs are legal. An implementation may group
// their clears, but it cannot turn those legal draws into an over-budget MRT.
let mut post = gpu.build(hdr_pack(sized, format));
let actual = gpu.render(&mut post, [16, 16]);
assert!(
actual.iter().all(|p| p
.iter()
.zip([0, 128, 0, 255])
.all(|(a, b)| a.abs_diff(b) <= 1)),
"sized={sized}: {:?}",
&actual[..16]
);
}
#[test]
fn fullsize_hdr_clear_respects_byte_budget() {
hdr_clear_case(false, 8, 32, "RGBA16F");
}
#[test]
fn sized_hdr_clear_respects_byte_budget() {
hdr_clear_case(true, 8, 32, "RGBA16F");
}
#[test]
fn fullsize_hdr_clear_respects_attachment_count() {
hdr_clear_case(false, 2, 64, "RGBA16F");
}
#[test]
fn sized_hdr_clear_respects_attachment_count() {
hdr_clear_case(true, 2, 64, "RGBA16F");
}
#[test]
fn fullsize_unorm_clear_respects_byte_budget() {
hdr_clear_case(false, 8, 32, "RGBA8");
}
#[test]
fn sized_unorm_clear_respects_byte_budget() {
hdr_clear_case(true, 8, 32, "RGBA8");
}
@@ -1,637 +0,0 @@
//! Original depth-buffer GPU regressions from public format documentation; no pack assets.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use wgpu::{TextureFormat as T, util::DeviceExt};
const SIZE: [u32; 2] = [16, 16];
struct Gpu {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
required_features: adapter.features() & wgpu::Features::FLOAT32_FILTERABLE,
..Default::default()
}))
.expect("GPU device required: setup failure is not a contract pass");
eprintln!("DEPTH_FIXTURE_ADAPTER {:?}", adapter.get_info());
Some(Self {
adapter,
device,
queue,
})
}
fn texture(&self, format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original depth fixture input"),
size: wgpu::Extent3d {
width: SIZE[0],
height: SIZE[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn load(
&self,
files: Vec<(String, String)>,
world: World<'_>,
inputs: horizon_shaders::GameInputs,
) -> PackPost {
use std::io::{Cursor, Write};
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, contents) in files {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(contents.as_bytes()).unwrap();
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-depth-fixture",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(
post.programs.iter().any(|p| p.name == "final"),
"{:?}",
post.notes
);
let built = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
world,
);
assert!(
built.passes.iter().any(|p| p.group == Group::Final),
"{:?}",
built.notes
);
built
}
fn clear(&self, color: &wgpu::TextureView, depth: &wgpu::TextureView, reverse_depth: f32) {
let mut encoder = self.device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Independent original fixture clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0625,
g: 0.0625,
b: 0.0625,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(reverse_depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
self.queue.submit([encoder.finish()]);
}
fn render(
&self,
post: &mut PackPost,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
frame: &Frame<'_>,
terrain: &mut TerrainDraw<'_>,
) -> Vec<[u8; 4]> {
let screen = self.texture(T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Original fixture readback"),
size: u64::from(stride * SIZE[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = self.device.create_command_encoder(&Default::default());
post.run(
&self.device,
&self.queue,
&mut encoder,
color,
depth,
&screen,
frame,
&Default::default(),
SIZE,
SIZE,
&[],
terrain,
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(stride),
rows_per_image: None,
},
},
screen.texture().size(),
);
self.queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..SIZE[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
}
fn all_pixels(pixels: &[[u8; 4]], expected: [u8; 4], name: &str) {
let wrong = pixels
.iter()
.filter(|p| p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1))
.count();
eprintln!(
"DEPTH_FIXTURE_RESULT {name} expected={expected:?} actual_first={:?} wrong={wrong}/{}",
pixels[0],
pixels.len()
);
assert_eq!(wrong, 0, "{name}: independent expected pixels {expected:?}");
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stage {
Fragment,
Deferred,
Attached,
ComputeOnly,
EarlierCompute,
}
#[derive(Clone, Copy, Debug)]
enum Scene {
World,
Terrain { hand: bool, water: bool },
WaterOnly,
}
const VERTEX: &str =
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
let names = if sampler == "all" {
vec!["depthtex0", "depthtex1", "depthtex2"]
} else {
vec![sampler]
};
let declarations = names
.iter()
.map(|s| format!("uniform sampler2D {s};\n"))
.collect::<String>();
let sample = |s| {
if matches!(stage, Stage::Fragment | Stage::Deferred) {
format!("texture2D({s},uv).r")
} else {
format!("texelFetch({s},p,0).r")
}
};
let result = if sampler == "all" {
format!(
"vec4({},{},{},1.0)",
sample(names[0]),
sample(names[1]),
sample(names[2])
)
} else {
format!("vec4({},0.25,0.5,1.0)", sample(names[0]))
};
let mut files = vec![
("final.vsh".into(), VERTEX.into()),
(
"shaders.properties".into(),
"blend.gbuffers_terrain=off\nblend.gbuffers_water=off\nblend.gbuffers_hand=off\n"
.into(),
),
];
if stage == Stage::Fragment {
files.push((
"final.fsh".into(),
format!(
"#version 120\n{declarations}varying vec2 uv;void main(){{gl_FragColor={result};}}"
),
));
} else if stage == Stage::Deferred {
files.push(("final.fsh".into(), "#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("deferred.vsh".into(), VERTEX.into()));
files.push(("deferred.fsh".into(), format!("#version 120\n/* DRAWBUFFERS:1 */\n{declarations}varying vec2 uv;void main(){{gl_FragData[0]={result};}}")));
} else {
files.push(("final.fsh".into(),"#version 120\nuniform sampler2D colortex1;varying vec2 uv;void main(){gl_FragColor=texture2D(colortex1,uv);}".into()));
files.push(("composite.csh".into(),format!("#version 430\nlayout(local_size_x=1,local_size_y=1)in;const ivec3 workGroups=ivec3(16,16,1);layout(rgba8) writeonly uniform image2D colorimg1;\n{declarations}\nvoid main(){{ivec2 p=ivec2(gl_GlobalInvocationID.xy);imageStore(colorimg1,p,{result});}}")));
match stage {
Stage::Attached => {
files.push(("composite.vsh".into(),VERTEX.into()));
files.push(("composite.fsh".into(),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0);}".into()));
}
Stage::EarlierCompute => files.push(("composite_a.csh".into(),"#version 430\nlayout(local_size_x=1)in;const ivec3 workGroups=ivec3(1);layout(rgba8) writeonly uniform image2D colorimg2;void main(){imageStore(colorimg2,ivec2(0),vec4(0.125));}".into())),
_ => {}
}
}
if !matches!(scene, Scene::World) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
for (name, clip) in [
("gbuffers_terrain", 0.4),
("gbuffers_water", -0.2),
("gbuffers_hand", -0.6),
] {
if name == "gbuffers_hand" && !hand {
continue;
}
if name == "gbuffers_terrain" && matches!(scene, Scene::WaterOnly) {
continue;
}
files.push((
format!("{name}.vsh"),
format!("#version 120\nvoid main(){{gl_Position=vec4(gl_Vertex.xy,{clip},1.0);}}"),
));
files.push((format!("{name}.fsh"),"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){gl_FragData[0]=vec4(0.0,0.0,0.0,1.0);}".into()));
}
}
files
}
fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let Some(gpu) = Gpu::new() else {
eprintln!("DEPTH_FIXTURE_NO_ADAPTER");
return;
};
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
// A seeded world depth of reverse-Z .3 is GL window depth .7.
gpu.clear(&color, &depth, 0.3);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let terrain = !matches!(scene, Scene::World);
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand,
},
inputs,
);
if terrain {
assert!(
post.terrain.is_some() != matches!(scene, Scene::WaterOnly) && post.water.is_some(),
"{:?}",
post.notes
);
}
if hand {
assert!(post.hand.is_some(), "{:?}", post.notes);
}
if stage == Stage::Deferred {
assert!(
post.passes.iter().any(|p| p.group == Group::Deferred),
"{:?}",
post.notes
);
} else if stage != Stage::Fragment {
let count = post
.passes
.iter()
.flat_map(|p| std::iter::once(p).chain(&p.compute))
.filter(|p| p.name.starts_with("composite"))
.count();
let expected = if matches!(stage, Stage::Attached | Stage::EarlierCompute) {
2
} else {
1
};
assert_eq!(
count, expected,
"all original compute/draw stages must build: {:?}",
post.notes
);
}
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original fixed depth geometry"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let quad = [[-1., -1., 0.], [1., -1., 0.], [1., 1., 0.], [-1., 1., 0.]].map(|pos| {
crate::mesher::Vertex {
pos,
uv: [0.; 2],
layer: 0,
color: u32::MAX,
light: 0,
normal: [0, 0, 127, 0],
glint_uv: [0.; 2],
}
});
let hud = HudBatch::new(SIZE);
let mut frame = fixture_frame(&hud);
if hand {
frame.hand = &quad;
}
let actual = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::Solid || (water && list == TerrainList::Translucent) {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
});
all_pixels(&actual, expected, &format!("{sampler} {stage:?} {scene:?}"));
}
#[test]
fn hand_is_in_depth1_and_excluded_from_depth2() {
check(
"all",
Stage::Fragment,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
#[test]
fn depth2_only_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn depth1_only_without_hand_excludes_water_control() {
check(
"depthtex1",
Stage::Fragment,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn water_only_program_populates_both_opaque_snapshots() {
check(
"all",
Stage::Fragment,
Scene::WaterOnly,
[102, 179, 179, 255],
);
}
#[test]
fn deferred_depth2_only_excludes_hand() {
check(
"depthtex2",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[179, 64, 128, 255],
);
}
#[test]
fn deferred_depth1_contains_hand_control() {
check(
"depthtex1",
Stage::Deferred,
Scene::Terrain {
hand: true,
water: false,
},
[51, 64, 128, 255],
);
}
#[test]
fn direct_fragment_gets_world_depth_control() {
check(
"depthtex0",
Stage::Fragment,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_gets_world_depth() {
check(
"depthtex0",
Stage::Attached,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn compute_only_gets_world_depth_control() {
check(
"depthtex0",
Stage::ComputeOnly,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn earlier_compute_child_gets_world_depth() {
check(
"depthtex0",
Stage::EarlierCompute,
Scene::World,
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth1_without_hand_excludes_water() {
check(
"depthtex1",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_depth2_without_hand_excludes_water() {
check(
"depthtex2",
Stage::Attached,
Scene::Terrain {
hand: false,
water: true,
},
[179, 64, 128, 255],
);
}
#[test]
fn attached_compute_preserves_distinct_hand_snapshots() {
check(
"all",
Stage::Attached,
Scene::Terrain {
hand: true,
water: true,
},
[51, 51, 179, 255],
);
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 90.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
@@ -1,456 +0,0 @@
//! Original asset-free post-output regressions, using only the public shader format.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use std::io::{Cursor, Write};
use wgpu::TextureFormat as T;
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
#[derive(Clone, Copy, Debug)]
enum Coverage {
Full,
Discard,
Vertex,
Scale,
}
fn pack(
coverage: Coverage,
mrt: bool,
keep_main: bool,
history: Option<bool>,
shadow: bool,
) -> 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();
};
let targets = match (shadow, mrt) {
(true, true) => "0,1",
(true, false) => "0",
(false, true) => "2,5",
(false, false) => "2",
};
let fragment = |first: &str, second: &str, discard: bool| {
format!(
"#version 120\n/* RENDERTARGETS:{targets} */\nvarying vec2 uv;\nvoid main() {{ {} gl_FragData[0]=vec4({first}); {} }}\n",
if discard { "if(uv.x<0.5) discard;" } else { "" },
if mrt {
format!("gl_FragData[1]=vec4({second});")
} else {
String::new()
},
)
};
let mut properties = if shadow {
"shadow.enabled=true\n".to_owned()
} else {
String::new()
};
if let Some(clear) = history {
add("composite.vsh", VERTEX);
add("composite.fsh", &fragment("0.25,0,0,1", "", false));
add("composite1.vsh", VERTEX);
add("composite1.fsh", &fragment("0,0.5,0,1", "", true));
add("composite2.vsh", VERTEX);
add(
"composite2.fsh",
"#version 120\n/* RENDERTARGETS:3 */\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
);
properties.push_str("flip.composite2.colortex2=true\n");
add(
"final.fsh",
&format!(
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\nconst bool colortex2Clear={clear};\nconst vec4 colortex2ClearColor=vec4(0,0.375,0.5,1);\nvoid main() {{ gl_FragColor=vec4(texture2D(colortex2,uv).r,texture2D(colortex3,uv).g,texture2D(colortex3,uv).b,1); }}\n"
),
);
} else {
// B gets red, A gets blue, then the partial pass writes B again. Outside writes
// must retain B's red, independently of the current A's blue.
for (name, first, second) in [
("composite", "0.25,0,0,1", "0,0.375,0,1"),
("composite1", "0,0,0.25,1", "0.25,0,0.25,1"),
("composite2", "0,0.5,0,1", "0,0.75,0,1"),
] {
let partial = name == "composite2";
let name = if shadow {
name.replace("composite", "shadowcomp")
} else {
name.to_owned()
};
let vertex = if partial && matches!(coverage, Coverage::Vertex) {
VERTEX.replace(
"ftransform();",
"ftransform(); gl_Position.x=gl_Position.x*0.5+gl_Position.w*0.5;",
)
} else {
VERTEX.to_owned()
};
add(&format!("{name}.vsh"), &vertex);
add(
&format!("{name}.fsh"),
&fragment(
first,
second,
partial && matches!(coverage, Coverage::Discard),
),
);
}
if matches!(coverage, Coverage::Scale) {
properties.push_str("scale.composite2=0.5 0.5 0.0\n");
}
if keep_main {
properties
.push_str("flip.composite2.colortex2=false\nflip.composite2.colortex5=false\n");
}
let final_source = if mrt {
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex5;\nvarying vec2 uv;\nvoid main() { gl_FragColor=vec4(texture2D(colortex2,uv).r,texture2D(colortex5,uv).g,texture2D(colortex2,uv).g,1); }\n"
} else {
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\nvoid main() { gl_FragColor=texture2D(colortex2,uv); }\n"
};
let final_source = if shadow {
final_source
.replace("colortex2", "shadowcolor0")
.replace("colortex5", "shadowcolor1")
.replace(
"#version 120",
"#version 120\nconst int shadowMapResolution=16;",
)
} else {
final_source.to_owned()
};
add("final.fsh", &final_source);
}
add("final.vsh", VERTEX);
add("shaders.properties", &properties);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-destination-contract",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(
post.programs.len(),
if shadow { 1 } else { 4 },
"{:?}",
post.notes
);
assert_eq!(
post.shadow_comp.len(),
if shadow { 3 } else { 0 },
"{:?}",
post.notes
);
post
}
struct Fixture {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Fixture {
fn new() -> Option<Self> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("Post destination test skipped: no GPU adapter");
return None;
};
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device for destination contract");
eprintln!("Post destination adapter: {:?}", adapter.get_info());
Some(Self {
adapter,
device,
queue,
})
}
fn texture(&self, size: [u32; 2], format: T) -> wgpu::TextureView {
self.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original post destination fixture"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn build(&self, post: Post) -> PackPost {
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
let post = PackPost::new(
&self.device,
&self.queue,
&self.adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap,
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [16, 16],
clouds: None,
weather: false,
hand: false,
},
);
assert!(
post.notes.iter().all(|n| n.starts_with("shadow map ")),
"{:?}",
post.notes
);
post
}
fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
let world = self.texture(size, T::Rgba8Unorm);
let depth = self.texture(size, T::Depth32Float);
let screen = self.texture(size, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = self.device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let hud = HudBatch::new(size);
let mut encoder = self.device.create_command_encoder(&Default::default());
post.run(
&self.device,
&self.queue,
&mut encoder,
&world,
&depth,
&screen,
&fixture_frame(&hud),
&Default::default(),
size,
size,
&[],
&mut |_, _, _, _| {},
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(stride),
..Default::default()
},
},
screen.texture().size(),
);
self.queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
}
#[test]
fn partial_writes_retain_the_alternate_with_flip_scale_and_mrt_controls() {
let Some(gpu) = Fixture::new() else { return };
let mut failures = Vec::new();
for (coverage, mrt, keep_main, shadow) in [
(Coverage::Full, false, false, false),
(Coverage::Discard, false, false, false),
(Coverage::Vertex, false, false, false),
(Coverage::Scale, false, false, false),
(Coverage::Discard, true, false, false),
(Coverage::Discard, false, true, false),
(Coverage::Full, false, false, true),
(Coverage::Discard, false, false, true),
(Coverage::Vertex, false, false, true),
(Coverage::Discard, true, false, true),
] {
let mut post = gpu.build(pack(coverage, mrt, keep_main, None, shadow));
// The same pack is also exercised after resize; all destination seeds come from
// real shader passes, never from the load operation under test.
for size in [[16, 16], [8, 8]] {
let actual = gpu.render(&mut post, size);
let wrong = actual
.iter()
.enumerate()
.filter(|(n, pixel)| {
let x = *n as u32 % size[0];
let y = *n as u32 / size[0];
let outside = match coverage {
Coverage::Full => false,
Coverage::Discard | Coverage::Vertex => x < size[0] / 2,
Coverage::Scale => x < size[0] / 2 || y < size[1] / 2,
};
let expected = match (keep_main, outside, mrt) {
(true, _, _) => [0, 0, 64, 255],
(false, true, false) => [64, 0, 0, 255],
(false, false, false) => [0, 128, 0, 255],
(false, true, true) => [64, 96, 0, 255],
(false, false, true) => [0, 191, 128, 255],
};
pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.count();
eprintln!(
"Destination {coverage:?} mrt={mrt} keep_main={keep_main} shadow={shadow} size={size:?}: wrong={wrong}/{}",
actual.len()
);
if wrong != 0 {
failures.push((coverage, mrt, keep_main, shadow, size, wrong));
}
}
}
assert!(
failures.is_empty(),
"Destination pixels differ: {failures:?}"
);
}
#[test]
fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
actual.iter().enumerate().all(|(n, p)| {
let outside = n % 16 < 8;
if frame == 0 && !clear && outside {
return true;
}
let expected = if !outside {
[64, 128, 0, 255]
} else if clear {
[64, 96, 128, 255]
} else {
[64, 0, 0, 255]
};
p.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1)
}),
"clear={clear} frame={frame}: {:?}",
&actual[..16]
);
}
}
}
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 0.0,
fov_degrees: 90.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
#[path = "clear_budget_tests.rs"]
mod clear_budget_tests;
@@ -1,371 +0,0 @@
//! Original offscreen inputs exercise the production blit and post-processing output.
use super::*;
use crate::{gfx::Frame, hud::HudBatch};
use std::io::{Cursor, Write};
use wgpu::TextureFormat as T;
#[derive(Clone, Copy, Debug)]
enum Change {
Colour,
Depth,
Both,
Contents,
Resize,
}
#[test]
fn replacing_only_world_colour_at_the_same_size_reads_the_new_view() {
replacement(Change::Colour, true);
}
#[test]
fn replacing_only_world_depth_at_the_same_size_reads_the_new_view() {
replacement(Change::Depth, true);
}
#[test]
fn replacing_both_world_views_at_the_same_size_matches_fresh_bindings() {
replacement(Change::Both, true);
}
#[test]
fn retained_world_views_read_updated_contents() {
replacement(Change::Contents, true);
}
#[test]
fn resized_world_views_match_fresh_bindings() {
replacement(Change::Resize, true);
}
#[test]
fn a_pack_without_final_reads_the_replaced_world_colour() {
replacement(Change::Colour, false);
}
fn replacement(change: Change, final_shader: bool) {
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(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
})) else {
return;
};
eprintln!("world-target adapter: {:?}", adapter.get_info());
let mut size = [4, 2];
let mut colour = texture(&device, size, T::Rgba8Unorm);
let mut depth = texture(&device, size, T::Depth32Float);
clear(&device, &queue, &colour, &depth, [32, 64, 16, 255], 0.25);
let mut post = pack(&device, &queue, &adapter, &colour, final_shader);
let hud = HudBatch::new(size);
let frame = frame(&hud);
let initial = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let expected = if final_shader {
[32, 64, 191, 255]
} else {
[32, 64, 16, 255]
};
assert!(
initial.iter().all(|pixel| *pixel == expected),
"{initial:?}"
);
if matches!(change, Change::Resize) {
size = [6, 3];
}
if matches!(change, Change::Colour | Change::Both | Change::Resize) {
colour = texture(&device, size, T::Rgba8Unorm);
}
if matches!(change, Change::Depth | Change::Both | Change::Resize) {
depth = texture(&device, size, T::Depth32Float);
}
let pixel = if matches!(change, Change::Depth) {
[32, 64, 16, 255]
} else {
[160, 96, 48, 255]
};
let reverse_depth = if matches!(change, Change::Colour) {
0.25
} else {
0.75
};
clear(&device, &queue, &colour, &depth, pixel, reverse_depth);
let actual = render(&device, &queue, &mut post, &colour, &depth, &frame, size);
let mut fresh = pack(&device, &queue, &adapter, &colour, final_shader);
let reference = render(&device, &queue, &mut fresh, &colour, &depth, &frame, size);
// Independently specified RG channels and GL depth (1 - reverse-Z), in UNORM8.
let expected = if !final_shader {
[160, 96, 48, 255]
} else if matches!(change, Change::Colour) {
[160, 96, 191, 255]
} else if matches!(change, Change::Depth) {
[32, 64, 64, 255]
} else {
[160, 96, 64, 255]
};
assert!(
reference.iter().all(|pixel| *pixel == expected),
"{reference:?}"
);
eprintln!("world-target {change:?}, final={final_shader}, size={size:?}: {actual:?}");
assert_eq!(
actual, reference,
"current world inputs must match fresh bindings"
);
}
fn texture(device: &wgpu::Device, [width, height]: [u32; 2], format: T) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Original world-target regression input"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn clear(
device: &wgpu::Device,
queue: &wgpu::Queue,
colour: &wgpu::TextureView,
depth: &wgpu::TextureView,
pixel: [u8; 4],
reverse_depth: f32,
) {
let [r, g, b, a] = pixel.map(|n| f64::from(n) / 255.0);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: colour,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(reverse_depth),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
queue.submit([encoder.finish()]);
}
fn pack(
device: &wgpu::Device,
queue: &wgpu::Queue,
adapter: &wgpu::Adapter,
lightmap: &wgpu::TextureView,
final_shader: bool,
) -> PackPost {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("shaders/shaders.properties", options)
.unwrap();
zip.write_all(b"oldLighting=false\n").unwrap();
if final_shader {
for (name, source) in [
(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}",
),
(
"final.fsh",
"#version 120\nvarying vec2 uv;uniform sampler2D colortex0;uniform sampler2D depthtex0;\nvoid main(){gl_FragColor=vec4(texture2D(colortex0,uv).rg,texture2D(depthtex0,uv).r,1.0);}",
),
] {
zip.start_file(format!("shaders/{name}"), options).unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-world-inputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse("oldLighting=false", &macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert!(post.notes.is_empty(), "{:?}", post.notes);
let post = PackPost::new(
device,
queue,
adapter,
post,
T::Rgba8Unorm,
World {
atlas: None,
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
},
);
assert!(post.notes.is_empty(), "{:?}", post.notes);
post
}
#[allow(clippy::too_many_arguments)]
fn render(
device: &wgpu::Device,
queue: &wgpu::Queue,
post: &mut PackPost,
colour: &wgpu::TextureView,
depth: &wgpu::TextureView,
frame: &Frame,
size: [u32; 2],
) -> Vec<[u8; 4]> {
let screen = texture(device, size, T::Rgba8Unorm);
let stride = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(stride * size[1]),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
post.run(
device,
queue,
&mut encoder,
colour,
depth,
&screen,
frame,
&Default::default(),
size,
size,
&[],
&mut |_, _, _, _| {},
);
encoder.copy_texture_to_buffer(
screen.texture().as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(stride),
rows_per_image: None,
},
},
screen.texture().size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
read.slice(..)
.get_mapped_range()
.unwrap()
.chunks_exact(stride as usize)
.flat_map(|row| {
row[..size[0] as usize * 4]
.as_chunks::<4>()
.0
.iter()
.copied()
})
.collect()
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
@@ -35,7 +35,7 @@ pub(super) fn upload(
let (view, sample_type) = match create(device, queue, &texture, features) {
Ok(created) => created,
Err(e) => {
notes.push(format!("{}: {e}", key(&t.stage, &t.name)));
notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
continue;
}
};
@@ -60,8 +60,8 @@ pub(super) fn upload(
Image::Atlas
} else {
notes.push(format!(
"{}: `{location}` is not provided yet",
key(&t.stage, &t.name)
"texture.{}.{}: `{location}` is not provided yet",
t.stage, t.name
));
continue;
};
@@ -83,11 +83,9 @@ pub(super) fn upload(
}
/// The stage name of `texture.<stage>.*` a program of `group` reads.
pub(super) fn stage(group: Group) -> &'static str {
fn stage(group: Group) -> &'static str {
match group {
Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
Group::Setup => "setup",
Group::Begin => "begin",
Group::Prepare => "prepare",
Group::Deferred => "deferred",
Group::Composite | Group::Final => "composite",
@@ -97,8 +95,7 @@ pub(super) fn stage(group: Group) -> &'static str {
/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
/// may hold one texture per dimension. Without one, a `customTexture` of that name and
/// dimension, which every program reads.
/// may hold one texture per dimension.
pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option<usize> {
let dimension = if ty.contains("1D") {
wgpu::TextureViewDimension::D1
@@ -108,27 +105,14 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let staged = textures.iter().position(|t| {
textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
})
})
}
/// The `shaders.properties` key a texture came from, for notes.
fn key(stage: &str, name: &str) -> String {
match stage {
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
_ => format!("texture.{stage}.{name}"),
}
}
/// `texture.noise`, if the pack has one.
pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
textures
@@ -332,47 +316,6 @@ mod tests {
);
}
/// Iris's `customTexture.<name>`: every program reads it under its own name and dimension;
/// a `texture.<stage>.<name>` of the same name comes first in its stage.
#[test]
fn custom_textures_reach_every_program() {
use wgpu::TextureViewDimension as D;
let every = horizon_shaders::EVERY_STAGE;
let textures = [
lightmap(every, "texClouds", D::D3),
lightmap("composite", "texClouds", D::D3),
lightmap(every, "colortex4", D::D2),
];
for group in [
Group::Gbuffers,
Group::Translucent,
Group::Shadow,
Group::Prepare,
Group::Deferred,
Group::ShadowComp,
] {
assert_eq!(
find(&textures, group, "sampler3D", "texClouds"),
Some(0),
"{group:?}"
);
}
// `final` reads the `composite` stage's textures too.
for group in [Group::Composite, Group::Final] {
assert_eq!(find(&textures, group, "sampler3D", "texClouds"), Some(1));
}
assert_eq!(
find(&textures, Group::Gbuffers, "sampler2D", "texClouds"),
None
);
// Only its own name: no other name of the same buffer.
assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), None);
assert_eq!(
find(&textures, Group::Final, "sampler2D", "colortex4"),
Some(2)
);
}
#[test]
fn texels_pack_into_their_format() {
let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
@@ -49,10 +49,6 @@ pub struct PackInputs {
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// `cloudTime`: how far the game's clouds have moved along X, in blocks (`clouds::drift`
/// times vanilla's 0.03 blocks a tick). Iris 1.11.7 gives it, though ShaderDoc doesn't list
/// it: observed with a probe pack, three game times 0.03 apart per tick.
pub cloud_time: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
@@ -61,11 +57,6 @@ pub struct PackInputs {
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
/// The dimension type's `ambient_light` (`ambientLight`).
pub ambient_light: f32,
/// The player's cave mood, 0..1 (`BiomeAmbientSoundsHandler`'s moodiness):
/// `playerMood` and `constantMood`.
pub mood: f32,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -261,9 +252,6 @@ pub const NAMES: &[&str] = &[
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"ambientLight",
"playerMood",
"constantMood",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
@@ -272,7 +260,6 @@ pub const NAMES: &[&str] = &[
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"cloudTime",
"isElytraFlying",
"cloudHeight",
];
@@ -395,10 +382,6 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
(view, projection)
}
/// The seconds a tick of a smoothing half-life (`wetnessHalflife`, `eyeBrightnessHalflife`,
/// `centerDepthHalflife`) lasts in Iris 1.11.7: measured, twice a game tick.
const SECONDS_A_TICK: f32 = 0.1;
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
target + (value - target) * 0.5f32.powf(dt / half_life)
@@ -445,29 +428,26 @@ impl FrameUniforms {
let tilt = self.shadow.sun_path_rotation;
let sun = to_view(body(frame.sky.sun_angle, tilt));
let moon = to_view(body(frame.sky.moon_angle, tilt));
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26), at
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
// centerDepthSmooth 0.1 s).
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
let half_life = if inputs.rain > self.wetness {
600.0
} else {
200.0
};
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
None => raw,
});
}
@@ -564,8 +544,7 @@ impl FrameUniforms {
"biome_precipitation" => Value::i(&[inputs.biome_precipitation]),
"temperature" => Value::f(&[inputs.temperature]),
"rainfall" => Value::f(&[inputs.rainfall]),
// Rounded down, as Iris 1.11.7 gives it (probe pack).
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.floor() as i32)),
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
"gbufferModelView" => m4(view),
"gbufferModelViewInverse" => m4(view.inverse()),
"gbufferProjection" => m4(projection),
@@ -578,10 +557,6 @@ impl FrameUniforms {
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
"cloudTime" => Value::f(&[inputs.cloud_time]),
"ambientLight" => Value::f(&[inputs.ambient_light]),
// Iris 1.11.7 gave both the same value in every scene measured.
"playerMood" | "constantMood" => Value::f(&[inputs.mood]),
// renderStage is NONE outside gbuffers draws.
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
@@ -925,25 +900,6 @@ mod initial_history_tests {
float(&u, "cameraPosition")
);
}
/// Iris 1.11.7 (probe pack, a world saved at game time 5073 and loaded about ten seconds
/// before): `cloudTime` read 157.98, 160.56 and 163.11, four seconds apart. That is the
/// clouds' drift at 0.03 blocks a tick, 5266 ticks in.
#[test]
fn cloud_time_is_how_far_the_clouds_drifted() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let drift = crate::clouds::drift(5266, 0.0, 256);
let input = PackInputs {
cloud_time: drift as f32 * 0.030_000_001,
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert!((float(&u, "cloudTime")[0] - 157.98).abs() < 1e-3);
// A trip across the 256-cell texture takes 256 * 400 ticks, then starts again.
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -966,275 +922,6 @@ mod initial_history_tests {
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
/// Actual provider bytes through translated GLSL and a GPU pixel, with a nonidentity
/// model-view/projection and a distant camera. Geometry is already camera-relative.
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn core_matrix_inputs_render_provider_values() {
use horizon_shaders::{
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
};
use std::io::Write;
use wgpu::util::DeviceExt;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let hud = HudBatch::new([800, 400]);
let mut f = frame(&hud);
f.camera = DVec3::new(1024.25, 100.5, -2048.75);
f.yaw = 180.0;
f.pitch = 0.0;
f.fov_degrees = 90.0;
f.bob = Mat4::from_translation(Vec3::new(0.5, -0.25, 0.0));
let mut values = FrameUniforms::default();
values.update(&f, &PackInputs::default(), [800, 400]);
for (declaration, setup, projection, view, expected) in [
(
"",
"",
"projectionMatrix",
"modelViewMatrix",
[64u8, 64, 128, 255],
),
(
"uniform mat4 projectionMatrix, modelViewMatrix;",
"",
"projectionMatrix",
"modelViewMatrix",
[64, 64, 128, 255],
),
(
"",
"",
"gl_ProjectionMatrix",
"gl_ModelViewMatrix",
[64, 64, 128, 255],
),
(
"",
"mat4 projectionMatrix = mat4(2.0); mat4 modelViewMatrix = mat4(3.0);",
"projectionMatrix",
"modelViewMatrix",
[128, 191, 255, 255],
),
(
"mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);",
"",
"projectionMatrix",
"modelViewMatrix",
[128, 191, 255, 255],
),
] {
let vertex = format!(
"#version 330 compatibility\n{declaration}\nout vec3 result;\n\
void main() {{\n{setup}\n\
vec4 q = {projection} * {view} * vec4(0.5, 0.75, -2.0, 1.0);\n\
result = vec3(q.xy / q.w, q.w / 4.0);\n\
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
);
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (path, source) in [
("shaders/gbuffers_line.vsh", vertex.as_str()),
(
"shaders/gbuffers_line.fsh",
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
),
] {
zip.start_file(path, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
}
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
.unwrap();
let macros = Macros::standard();
let program = translate_gbuffers(
&preprocess(&pack, "gbuffers_line.vsh", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
Target::Buffers,
&GbuffersInputs::default(),
)
.unwrap();
let mut bytes = vec![0; program.interface.uniform_size as usize];
for uniform in &program.interface.uniforms {
if let Some(i) = index(&uniform.name) {
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
}
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::UNIFORM,
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex);
let fs = module(program.fragment);
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
let texture = 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: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = read.slice(..).get_mapped_range().unwrap();
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
for (actual, expected) in mapped[..4].iter().zip(expected) {
assert!(
actual.abs_diff(expected) <= 1,
"{projection}: {:?}",
&mapped[..4]
);
}
}
}
/// Iris 1.11.7, read frame by frame with a probe pack: eyeBrightnessSmooth falls by half
/// in 1 s and is rounded down; wetness takes 60 s to rise halfway and 20 s to fall; the
/// centre depth 0.1 s.
#[test]
fn smoothed_uniforms_take_iris_half_lives() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
// The next frame comes `seconds` after the last (and a few microseconds of the test).
let after = |u: &mut FrameUniforms, seconds: f32| {
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
};
let mut input = PackInputs {
eye_light: [0, 15],
..Default::default()
};
u.center_depth_raw = Some(1.0);
u.update(&f, &input, [800, 450]);
input.eye_light = [0, 0];
u.center_depth_raw = Some(0.0);
after(&mut u, 0.1);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
after(&mut u, 0.6);
u.update(&f, &input, [800, 450]);
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(v[1], 147),
_ => panic!("integer uniform expected"),
}
input.rain = 1.0;
after(&mut u, 60.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
input.rain = 0.0;
after(&mut u, 20.0);
u.update(&f, &input, [800, 450]);
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
}
/// 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]
fn ambient_light_and_mood_come_from_the_inputs() {
let hud = HudBatch::new([800, 450]);
let mut u = FrameUniforms::default();
let input = PackInputs {
ambient_light: 0.25,
mood: 0.0235,
..Default::default()
};
u.update(&frame(&hud), &input, [800, 450]);
assert_eq!(float(&u, "ambientLight")[0], 0.25);
assert_eq!(float(&u, "playerMood")[0], 0.0235);
assert_eq!(float(&u, "constantMood")[0], 0.0235);
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
@@ -1277,15 +964,11 @@ mod initial_history_tests {
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
@@ -29,6 +29,11 @@ pub(super) struct Run {
pub tag: [u32; 3],
}
/// The band of a section `distance` from the camera (squared, as bits).
pub(super) fn band(distance: u64) -> u64 {
(f64::from_bits(distance).sqrt() / BAND) as u64
}
#[derive(Default)]
pub(super) struct PageRuns {
runs: Vec<Run>,
@@ -58,7 +63,7 @@ impl PageRuns {
tag: [u32; 3],
) {
if len > 0 {
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
let band = band(distance);
self.runs.push(Run {
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
@@ -5,10 +5,7 @@
#[cfg(test)]
use super::Frustum;
use super::{Cull, SectionKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
streaming::in_view_distance,
};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
/// One chunk column's resident sections.
@@ -58,22 +55,6 @@ impl<T> Resident<T> {
pub fn len(&self) -> usize {
self.items.len()
}
/// Heap bytes of its own lists and maps, not counting what a `T` points
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
/// control byte.
pub fn heap_bytes(&self) -> usize {
use std::mem::size_of;
self.items.capacity() * size_of::<(SectionKey, T)>()
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
+ self.columns.capacity() * size_of::<Column>()
+ self
.columns
.iter()
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
.sum::<usize>()
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
}
pub fn contains(&self, key: SectionKey) -> bool {
self.slots.contains_key(&key)
}
@@ -83,15 +64,6 @@ impl<T> Resident<T> {
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key and value.
#[cfg(debug_assertions)]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T)> {
self.items.iter().map(|(key, value)| (*key, value))
}
/// Every section's key and value.
pub fn iter_mut(&mut self) -> impl Iterator<Item = (SectionKey, &mut T)> {
self.items.iter_mut().map(|(key, value)| (*key, value))
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -111,10 +83,6 @@ impl<T> Resident<T> {
pub fn set_min_y(&mut self, min_y: i32) {
self.min_y = min_y;
}
/// The minimum Y of section index 0.
pub fn min_y(&self) -> i32 {
self.min_y
}
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
@@ -157,35 +125,6 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
// Every column (a far teleport): emptied, without a lookup per section.
if self.columns.iter().all(|c| gone.contains(&(c.x, c.z))) {
self.bounds.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
return std::mem::take(&mut self.items)
.into_iter()
.map(|(_, value)| value)
.collect();
}
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
self.columns.clear();
self.column_index.clear();
let mut removed = Vec::new();
for ((key, value), bounds) in items.into_iter().zip(bounds) {
if gone.contains(&(key.0, key.2)) {
removed.push(value);
} else {
self.insert_with_bounds(key, value, bounds);
}
}
removed
}
pub fn remove(&mut self, key: SectionKey) -> Option<T> {
let slot = self.slots.remove(&key)?;
let c = self.column_index[&(key.0, key.2)] as usize;
@@ -236,7 +175,7 @@ pub struct Visible {
}
/// The camera-relative origin of the section at `key`.
pub(super) fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
DVec3::new(
f64::from(key.0 * 16),
f64::from(min_y + key.1 * 16),
@@ -245,7 +184,7 @@ pub(super) fn origin(key: SectionKey, min_y: i32, camera: DVec3) -> DVec3 {
}
/// The camera's chunk column.
pub(super) fn camera_column(camera: DVec3) -> (i32, i32) {
fn camera_column(camera: DVec3) -> (i32, i32) {
(
(camera.x.floor() as i32) >> 4,
(camera.z.floor() as i32) >> 4,
@@ -397,7 +336,7 @@ impl<T> Resident<T> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
#[test]
fn column_walk_finds_exactly_the_sections_in_view() {
@@ -557,49 +496,13 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(389) {
// Every column at once (a far teleport), and one that holds nothing.
let gone: FxHashSet<(i32, i32)> = (-2..=3)
.flat_map(|x| (-2..=2).map(move |z| (x, z)))
.collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model.drain().map(|(_, v)| v).collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
assert!(r.columns.is_empty() && r.column_index.is_empty());
} else if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model
.extract_if(|k: &SectionKey, _| gone.contains(&(k.0, k.2)))
.map(|(_, v)| v)
.collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key));
} else {
r.insert(key, step);
model.insert(key, step);
}
check(&r, &model);
// `iter_mut` yields every key once, with its value.
let mut seen = HashSet::new();
for (key, value) in r.iter_mut() {
assert_eq!(*value, model[&key], "{key:?}");
assert!(seen.insert(key), "{key:?} twice");
}
assert_eq!(seen.len(), model.len());
}
for (_, value) in r.iter_mut() {
*value += 1;
}
for (key, value) in &model {
assert_eq!(r.find(*key), Some(&(value + 1)));
}
r.clear();
assert_eq!(r.len(), 0);
Loaded 100 of 1235 files, more files were not shown because too many files have changed in this diff. Show more