Merge origin/main into fix/server-forced-flight

Keep the optional no-default-features probe build on the new horizon-client-lib package.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Maksymilian AdamkiewiczandCodex committed 2026-11-02 01:16:27 +01:00
commit b440c53cee
527 files changed
+38390 -3927

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+61
View File
@@ -0,0 +1,61 @@
name: pack load bench
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
# start a workflow its main lacks), against horizon-rust/horizon's main.
on:
push:
branches: ['pack-bench/**']
workflow_dispatch:
inputs:
base:
description: Ref to compare against
default: main
scale:
description: How many times the default pack sizes
default: '1'
permissions:
contents: read
env:
HORIZON_TELEMETRY_URL: "off"
CARGO_TERM_COLOR: never
jobs:
bench:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: pack-bench
- name: Bench the branch
env:
SCALE: ${{ inputs.scale || '1' }}
run: |
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
- name: Bench the base, with the branch's bench
env:
BASE: ${{ inputs.base || 'main' }}
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
run: |
{
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
} | tee -a "$GITHUB_STEP_SUMMARY"
+16 -9
View File
@@ -40,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -85,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -103,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
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.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- 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
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -129,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -152,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -161,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+58
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1374,6 +1375,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
@@ -1550,6 +1607,7 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 113.978,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 1524.333,
"stdev": 364.142,
"min": 1112.127,
"max": 1802.295,
"ci95": 904.651
},
"startup.world_ms": {
"n": 3,
"mean": 14001.774,
"stdev": 8119.623,
"min": 7466.904,
"max": 23091.569,
"ci95": 20171.889
},
"chunks.village.load_ms": {
"n": 3,
"mean": 8159.696,
"stdev": 3897.327,
"min": 5313.528,
"max": 12601.663,
"ci95": 9682.279
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 25431.333,
"stdev": 1389.728,
"min": 24149.0,
"max": 26908.0,
"ci95": 3452.555
},
"fps.village.avg_fps": {
"n": 3,
"mean": 568.391,
"stdev": 11.948,
"min": 558.517,
"max": 581.673,
"ci95": 29.684
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 270.644,
"stdev": 9.955,
"min": 259.156,
"max": 276.728,
"ci95": 24.732
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 209.951,
"stdev": 30.24,
"min": 191.819,
"max": 244.86,
"ci95": 75.126
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 8879.707,
"stdev": 1758.259,
"min": 7205.686,
"max": 10711.562,
"ci95": 4368.11
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 29366.667,
"stdev": 273.35,
"min": 29053.0,
"max": 29554.0,
"ci95": 679.094
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 333.026,
"stdev": 1.469,
"min": 331.478,
"max": 334.4,
"ci95": 3.649
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 193.014,
"stdev": 11.504,
"min": 185.57,
"max": 206.264,
"ci95": 28.58
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 137.067,
"stdev": 28.701,
"min": 108.337,
"max": 165.738,
"ci95": 71.302
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 6399.435,
"stdev": 2268.592,
"min": 4346.111,
"max": 8834.786,
"ci95": 5635.949
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 25436.0,
"stdev": 813.154,
"min": 24718.0,
"max": 26319.0,
"ci95": 2020.149
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 411.986,
"stdev": 1.42,
"min": 410.981,
"max": 413.61,
"ci95": 3.527
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 209.307,
"stdev": 10.427,
"min": 197.391,
"max": 216.759,
"ci95": 25.905
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 126.88,
"stdev": 17.642,
"min": 106.534,
"max": 137.93,
"ci95": 43.829
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 14672.976,
"stdev": 9832.9,
"min": 8230.723,
"max": 25990.951,
"ci95": 24428.25
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 27715.0,
"stdev": 1706.033,
"min": 26572.0,
"max": 29676.0,
"ci95": 4238.362
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 357.149,
"stdev": 2.391,
"min": 355.009,
"max": 359.73,
"ci95": 5.941
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 199.658,
"stdev": 12.172,
"min": 187.504,
"max": 211.847,
"ci95": 30.238
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 154.465,
"stdev": 13.484,
"min": 144.923,
"max": 169.891,
"ci95": 33.498
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1892.0,
"stdev": 49.025,
"min": 1835.406,
"max": 1921.438,
"ci95": 121.794
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1616.755,
"stdev": 47.256,
"min": 1564.734,
"max": 1657.031,
"ci95": 117.4
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3083.815,
"stdev": 25.002,
"min": 3062.456,
"max": 3111.315,
"ci95": 62.113
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2495.271,
"stdev": 36.569,
"min": 2453.284,
"max": 2520.151,
"ci95": 90.85
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 6981,
"seconds": 12.002,
"avg_fps": 581.673,
"low_1pct_fps": 276.049,
"low_01pct_fps": 191.819,
"p50_ms": 1.655,
"p95_ms": 2.267,
"p99_ms": 3.277,
"p999_ms": 4.174,
"max_ms": 7.418,
"cpu_p50_ms": 0.955,
"gpu_p50_ms": 1.646,
"histogram": [
0,
6445,
353,
176,
3,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 6703,
"seconds": 12.001,
"avg_fps": 558.517,
"low_1pct_fps": 276.728,
"low_01pct_fps": 244.86,
"p50_ms": 1.725,
"p95_ms": 2.341,
"p99_ms": 3.396,
"p999_ms": 3.939,
"max_ms": 4.185,
"cpu_p50_ms": 0.767,
"gpu_p50_ms": 1.708,
"histogram": [
17,
6041,
442,
202,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 6780,
"seconds": 12.0,
"avg_fps": 564.982,
"low_1pct_fps": 259.156,
"low_01pct_fps": 193.174,
"p50_ms": 1.707,
"p95_ms": 2.311,
"p99_ms": 3.407,
"p999_ms": 4.984,
"max_ms": 5.322,
"cpu_p50_ms": 0.923,
"gpu_p50_ms": 1.698,
"histogram": [
5,
6118,
480,
163,
8,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 3978,
"seconds": 12.001,
"avg_fps": 331.478,
"low_1pct_fps": 187.208,
"low_01pct_fps": 137.127,
"p50_ms": 2.93,
"p95_ms": 3.969,
"p99_ms": 4.719,
"p999_ms": 6.451,
"max_ms": 8.835,
"cpu_p50_ms": 0.857,
"gpu_p50_ms": 2.92,
"histogram": [
0,
29,
2279,
1541,
109,
14,
4,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.199,
"low_1pct_fps": 185.57,
"low_01pct_fps": 108.337,
"p50_ms": 2.921,
"p95_ms": 3.862,
"p99_ms": 4.679,
"p999_ms": 6.817,
"max_ms": 11.683,
"cpu_p50_ms": 0.828,
"gpu_p50_ms": 2.903,
"histogram": [
3,
16,
2356,
1511,
97,
12,
1,
0,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4013,
"seconds": 12.001,
"avg_fps": 334.4,
"low_1pct_fps": 206.264,
"low_01pct_fps": 165.738,
"p50_ms": 2.915,
"p95_ms": 3.809,
"p99_ms": 4.525,
"p999_ms": 5.078,
"max_ms": 8.228,
"cpu_p50_ms": 0.861,
"gpu_p50_ms": 2.899,
"histogram": [
1,
18,
2357,
1553,
77,
5,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4932,
"seconds": 12.001,
"avg_fps": 410.981,
"low_1pct_fps": 216.759,
"low_01pct_fps": 137.93,
"p50_ms": 2.353,
"p95_ms": 3.181,
"p99_ms": 4.01,
"p999_ms": 5.767,
"max_ms": 9.713,
"cpu_p50_ms": 0.888,
"gpu_p50_ms": 2.343,
"histogram": [
0,
163,
4474,
264,
23,
4,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 4937,
"seconds": 12.001,
"avg_fps": 411.366,
"low_1pct_fps": 213.772,
"low_01pct_fps": 136.177,
"p50_ms": 2.349,
"p95_ms": 3.258,
"p99_ms": 4.015,
"p999_ms": 5.985,
"max_ms": 8.539,
"cpu_p50_ms": 0.871,
"gpu_p50_ms": 2.339,
"histogram": [
0,
202,
4406,
295,
25,
5,
1,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 4963,
"seconds": 11.999,
"avg_fps": 413.61,
"low_1pct_fps": 197.391,
"low_01pct_fps": 106.534,
"p50_ms": 2.335,
"p95_ms": 3.214,
"p99_ms": 4.111,
"p999_ms": 7.65,
"max_ms": 11.399,
"cpu_p50_ms": 0.878,
"gpu_p50_ms": 2.323,
"histogram": [
2,
228,
4417,
274,
28,
7,
2,
2,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 4317,
"seconds": 12.001,
"avg_fps": 359.73,
"low_1pct_fps": 211.847,
"low_01pct_fps": 169.891,
"p50_ms": 2.7,
"p95_ms": 3.753,
"p99_ms": 4.366,
"p999_ms": 5.041,
"max_ms": 8.726,
"cpu_p50_ms": 1.113,
"gpu_p50_ms": 2.687,
"histogram": [
0,
68,
3445,
718,
80,
5,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 4281,
"seconds": 12.001,
"avg_fps": 356.707,
"low_1pct_fps": 199.624,
"low_01pct_fps": 144.923,
"p50_ms": 2.712,
"p95_ms": 3.806,
"p99_ms": 4.535,
"p999_ms": 5.453,
"max_ms": 8.354,
"cpu_p50_ms": 1.111,
"gpu_p50_ms": 2.694,
"histogram": [
0,
64,
3353,
717,
138,
5,
2,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 4261,
"seconds": 12.003,
"avg_fps": 355.009,
"low_1pct_fps": 187.504,
"low_01pct_fps": 148.582,
"p50_ms": 2.727,
"p95_ms": 4.083,
"p99_ms": 4.744,
"p999_ms": 6.091,
"max_ms": 7.442,
"cpu_p50_ms": 1.126,
"gpu_p50_ms": 2.711,
"histogram": [
0,
155,
3198,
719,
165,
17,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 28.236,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 2190.186,
"stdev": 1780.667,
"min": 906.481,
"max": 4223.028,
"ci95": 4423.779
},
"startup.world_ms": {
"n": 3,
"mean": 14254.027,
"stdev": 4604.903,
"min": 10130.899,
"max": 19223.339,
"ci95": 11440.138
},
"chunks.village.load_ms": {
"n": 3,
"mean": 6509.117,
"stdev": 1616.283,
"min": 5457.214,
"max": 8370.17,
"ci95": 4015.394
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 48522.333,
"stdev": 1713.943,
"min": 46732.0,
"max": 50148.0,
"ci95": 4258.014
},
"fps.village.avg_fps": {
"n": 3,
"mean": 326.473,
"stdev": 6.811,
"min": 319.588,
"max": 333.208,
"ci95": 16.921
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 152.497,
"stdev": 34.142,
"min": 116.575,
"max": 184.525,
"ci95": 84.82
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 122.567,
"stdev": 33.914,
"min": 87.235,
"max": 154.858,
"ci95": 84.254
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 10363.339,
"stdev": 3122.178,
"min": 6760.012,
"max": 12265.121,
"ci95": 7756.547
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 82591.667,
"stdev": 1489.69,
"min": 81053.0,
"max": 84027.0,
"ci95": 3700.895
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 165.414,
"stdev": 4.368,
"min": 162.757,
"max": 170.455,
"ci95": 10.851
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 103.992,
"stdev": 20.41,
"min": 83.527,
"max": 124.346,
"ci95": 50.705
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 94.785,
"stdev": 24.344,
"min": 69.796,
"max": 118.428,
"ci95": 60.478
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 10450.642,
"stdev": 2599.832,
"min": 7510.454,
"max": 12445.71,
"ci95": 6458.863
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 76405.667,
"stdev": 649.936,
"min": 75681.0,
"max": 76937.0,
"ci95": 1614.662
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 210.179,
"stdev": 5.279,
"min": 205.559,
"max": 215.932,
"ci95": 13.114
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 128.777,
"stdev": 30.982,
"min": 93.017,
"max": 147.571,
"ci95": 76.97
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 115.916,
"stdev": 39.398,
"min": 70.443,
"max": 139.838,
"ci95": 97.879
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 17168.619,
"stdev": 3613.606,
"min": 13749.902,
"max": 20949.796,
"ci95": 8977.42
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 91622.667,
"stdev": 482.189,
"min": 91089.0,
"max": 92027.0,
"ci95": 1197.921
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 190.088,
"stdev": 2.175,
"min": 187.78,
"max": 192.099,
"ci95": 5.403
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 108.92,
"stdev": 14.244,
"min": 92.641,
"max": 119.094,
"ci95": 35.388
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 91.132,
"stdev": 12.82,
"min": 76.343,
"max": 99.09,
"ci95": 31.849
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2536.536,
"stdev": 40.31,
"min": 2506.281,
"max": 2582.297,
"ci95": 100.144
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2195.922,
"stdev": 37.757,
"min": 2167.078,
"max": 2238.656,
"ci95": 93.8
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5559.837,
"stdev": 47.18,
"min": 5505.457,
"max": 5589.863,
"ci95": 117.211
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4373.034,
"stdev": 82.743,
"min": 4311.909,
"max": 4467.191,
"ci95": 205.561
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 3920,
"seconds": 12.002,
"avg_fps": 326.623,
"low_1pct_fps": 116.575,
"low_01pct_fps": 87.235,
"p50_ms": 2.938,
"p95_ms": 4.356,
"p99_ms": 7.397,
"p999_ms": 9.497,
"max_ms": 13.274,
"cpu_p50_ms": 1.611,
"gpu_p50_ms": 2.871,
"histogram": [
0,
115,
2297,
1271,
149,
33,
10,
24,
18,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 3835,
"seconds": 12.0,
"avg_fps": 319.588,
"low_1pct_fps": 156.39,
"low_01pct_fps": 125.608,
"p50_ms": 3.04,
"p95_ms": 4.322,
"p99_ms": 5.265,
"p999_ms": 7.732,
"max_ms": 8.183,
"cpu_p50_ms": 1.733,
"gpu_p50_ms": 2.936,
"histogram": [
0,
1,
1610,
2006,
142,
51,
15,
10,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 3999,
"seconds": 12.002,
"avg_fps": 333.208,
"low_1pct_fps": 184.525,
"low_01pct_fps": 154.858,
"p50_ms": 2.9,
"p95_ms": 4.01,
"p99_ms": 4.955,
"p999_ms": 6.193,
"max_ms": 6.805,
"cpu_p50_ms": 1.51,
"gpu_p50_ms": 2.788,
"histogram": [
0,
8,
2499,
1328,
132,
25,
7,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 1957,
"seconds": 12.004,
"avg_fps": 163.03,
"low_1pct_fps": 83.527,
"low_01pct_fps": 69.796,
"p50_ms": 6.011,
"p95_ms": 8.032,
"p99_ms": 10.903,
"p999_ms": 14.322,
"max_ms": 14.333,
"cpu_p50_ms": 1.555,
"gpu_p50_ms": 5.857,
"histogram": [
0,
5,
26,
16,
36,
883,
739,
194,
29,
22,
5,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1954,
"seconds": 12.006,
"avg_fps": 162.757,
"low_1pct_fps": 104.102,
"low_01pct_fps": 96.13,
"p50_ms": 5.98,
"p95_ms": 7.665,
"p99_ms": 9.049,
"p999_ms": 10.323,
"max_ms": 10.482,
"cpu_p50_ms": 1.584,
"gpu_p50_ms": 5.715,
"histogram": [
0,
0,
0,
1,
9,
1028,
737,
130,
46,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 2046,
"seconds": 12.003,
"avg_fps": 170.455,
"low_1pct_fps": 124.346,
"low_01pct_fps": 118.428,
"p50_ms": 5.719,
"p95_ms": 7.342,
"p99_ms": 7.815,
"p999_ms": 8.38,
"max_ms": 8.506,
"cpu_p50_ms": 1.384,
"gpu_p50_ms": 5.572,
"histogram": [
0,
0,
0,
0,
26,
1452,
396,
169,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 2467,
"seconds": 12.001,
"avg_fps": 205.559,
"low_1pct_fps": 93.017,
"low_01pct_fps": 70.443,
"p50_ms": 4.708,
"p95_ms": 6.6,
"p99_ms": 9.432,
"p999_ms": 13.566,
"max_ms": 15.228,
"cpu_p50_ms": 1.664,
"gpu_p50_ms": 4.483,
"histogram": [
0,
18,
55,
87,
1811,
290,
143,
27,
20,
13,
2,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 2509,
"seconds": 12.002,
"avg_fps": 209.045,
"low_1pct_fps": 145.742,
"low_01pct_fps": 139.838,
"p50_ms": 4.642,
"p95_ms": 6.15,
"p99_ms": 6.72,
"p999_ms": 7.081,
"max_ms": 7.254,
"cpu_p50_ms": 1.74,
"gpu_p50_ms": 4.376,
"histogram": [
0,
0,
0,
78,
2008,
277,
142,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2591,
"seconds": 11.999,
"avg_fps": 215.932,
"low_1pct_fps": 147.571,
"low_01pct_fps": 137.466,
"p50_ms": 4.466,
"p95_ms": 5.947,
"p99_ms": 6.512,
"p999_ms": 7.262,
"max_ms": 7.283,
"cpu_p50_ms": 1.422,
"gpu_p50_ms": 4.286,
"histogram": [
0,
2,
0,
111,
2114,
248,
111,
5,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 2285,
"seconds": 12.002,
"avg_fps": 190.386,
"low_1pct_fps": 92.641,
"low_01pct_fps": 76.343,
"p50_ms": 5.038,
"p95_ms": 7.005,
"p99_ms": 9.383,
"p999_ms": 11.731,
"max_ms": 15.266,
"cpu_p50_ms": 2.239,
"gpu_p50_ms": 4.915,
"histogram": [
0,
0,
54,
45,
916,
985,
168,
78,
22,
15,
1,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 2306,
"seconds": 12.004,
"avg_fps": 192.099,
"low_1pct_fps": 119.094,
"low_01pct_fps": 99.09,
"p50_ms": 5.017,
"p95_ms": 6.58,
"p99_ms": 7.586,
"p999_ms": 9.195,
"max_ms": 11.428,
"cpu_p50_ms": 2.261,
"gpu_p50_ms": 4.823,
"histogram": [
0,
0,
3,
12,
1070,
1003,
165,
43,
9,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 2254,
"seconds": 12.003,
"avg_fps": 187.78,
"low_1pct_fps": 115.026,
"low_01pct_fps": 97.962,
"p50_ms": 5.175,
"p95_ms": 6.791,
"p99_ms": 7.916,
"p999_ms": 9.284,
"max_ms": 11.986,
"cpu_p50_ms": 2.158,
"gpu_p50_ms": 4.942,
"histogram": [
0,
0,
0,
17,
774,
1199,
182,
69,
12,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,758 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "horizon-client",
"version": "0.1.0",
"commit": "1bd24e4042"
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "",
"driver": "",
"backend": "metal",
"kind": "integrated"
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 73.292,
"window": [
1920,
1080
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 7204.239,
"stdev": 1586.64,
"min": 5409.683,
"max": 8421.051,
"ci95": 3941.749
},
"startup.world_ms": {
"n": 3,
"mean": 11407.51,
"stdev": 2085.692,
"min": 9027.004,
"max": 12913.993,
"ci95": 5181.563
},
"chunks.village.load_ms": {
"n": 3,
"mean": 5116.146,
"stdev": 744.611,
"min": 4548.096,
"max": 5959.13,
"ci95": 1849.865
},
"chunks.village.meshed_count": {
"n": 3,
"mean": 7220.0,
"stdev": 137.292,
"min": 7092.0,
"max": 7365.0,
"ci95": 341.079
},
"fps.village.avg_fps": {
"n": 3,
"mean": 540.213,
"stdev": 182.263,
"min": 432.911,
"max": 750.659,
"ci95": 452.803
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 195.561,
"stdev": 75.888,
"min": 141.859,
"max": 282.38,
"ci95": 188.533
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 134.559,
"stdev": 74.125,
"min": 87.663,
"max": 220.016,
"ci95": 184.153
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 7185.67,
"stdev": 4093.192,
"min": 4323.79,
"max": 11874.133,
"ci95": 10168.874
},
"chunks.forest.meshed_count": {
"n": 3,
"mean": 8302.667,
"stdev": 538.894,
"min": 7853.0,
"max": 8900.0,
"ci95": 1338.794
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 364.068,
"stdev": 8.947,
"min": 355.173,
"max": 373.067,
"ci95": 22.229
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 145.064,
"stdev": 21.824,
"min": 121.995,
"max": 165.382,
"ci95": 54.218
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 97.299,
"stdev": 15.364,
"min": 81.075,
"max": 111.627,
"ci95": 38.17
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 4944.497,
"stdev": 1598.757,
"min": 3816.976,
"max": 6774.175,
"ci95": 3971.854
},
"chunks.ocean.meshed_count": {
"n": 3,
"mean": 7041.0,
"stdev": 276.949,
"min": 6821.0,
"max": 7352.0,
"ci95": 688.036
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 410.281,
"stdev": 35.314,
"min": 370.881,
"max": 439.08,
"ci95": 87.731
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 161.057,
"stdev": 15.769,
"min": 144.003,
"max": 175.109,
"ci95": 39.175
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 111.852,
"stdev": 16.415,
"min": 93.045,
"max": 123.299,
"ci95": 40.78
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 8844.497,
"stdev": 4291.678,
"min": 5269.587,
"max": 13604.075,
"ci95": 10661.98
},
"chunks.mountain.meshed_count": {
"n": 3,
"mean": 7381.0,
"stdev": 88.606,
"min": 7290.0,
"max": 7467.0,
"ci95": 220.127
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 466.294,
"stdev": 2.738,
"min": 464.707,
"max": 469.455,
"ci95": 6.801
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 164.71,
"stdev": 21.73,
"min": 141.437,
"max": 184.468,
"ci95": 53.984
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 90.643,
"stdev": 14.864,
"min": 76.851,
"max": 106.387,
"ci95": 36.928
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1536.969,
"stdev": 19.205,
"min": 1516.188,
"max": 1554.063,
"ci95": 47.711
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1379.422,
"stdev": 9.712,
"min": 1371.438,
"max": 1390.234,
"ci95": 24.129
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2093.701,
"stdev": 13.714,
"min": 2078.862,
"max": 2105.909,
"ci95": 34.07
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 1883.495,
"stdev": 10.346,
"min": 1871.987,
"max": 1892.026,
"ci95": 25.702
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 9008,
"seconds": 12.0,
"avg_fps": 750.659,
"low_1pct_fps": 282.38,
"low_01pct_fps": 220.016,
"p50_ms": 1.047,
"p95_ms": 2.169,
"p99_ms": 3.163,
"p999_ms": 4.077,
"max_ms": 5.462,
"cpu_p50_ms": 0.447,
"gpu_p50_ms": 1.021,
"histogram": [
3979,
3877,
1033,
112,
6,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 5245,
"seconds": 12.0,
"avg_fps": 437.07,
"low_1pct_fps": 141.859,
"low_01pct_fps": 87.663,
"p50_ms": 1.822,
"p95_ms": 4.165,
"p99_ms": 5.399,
"p999_ms": 10.052,
"max_ms": 12.712,
"cpu_p50_ms": 0.652,
"gpu_p50_ms": 1.943,
"histogram": [
831,
1968,
606,
1582,
154,
82,
3,
3,
10,
4,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 5195,
"seconds": 12.0,
"avg_fps": 432.911,
"low_1pct_fps": 162.445,
"low_01pct_fps": 95.997,
"p50_ms": 1.806,
"p95_ms": 4.202,
"p99_ms": 5.259,
"p999_ms": 7.591,
"max_ms": 17.328,
"cpu_p50_ms": 0.636,
"gpu_p50_ms": 1.876,
"histogram": [
297,
2502,
650,
1469,
192,
73,
6,
1,
3,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 4262,
"seconds": 12.0,
"avg_fps": 355.173,
"low_1pct_fps": 165.382,
"low_01pct_fps": 111.627,
"p50_ms": 2.917,
"p95_ms": 3.854,
"p99_ms": 5.022,
"p999_ms": 7.808,
"max_ms": 9.769,
"cpu_p50_ms": 0.59,
"gpu_p50_ms": 2.91,
"histogram": [
98,
416,
2506,
1080,
118,
29,
10,
1,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 4477,
"seconds": 12.001,
"avg_fps": 373.067,
"low_1pct_fps": 121.995,
"low_01pct_fps": 81.075,
"p50_ms": 2.243,
"p95_ms": 4.687,
"p99_ms": 5.835,
"p999_ms": 11.831,
"max_ms": 13.367,
"cpu_p50_ms": 0.541,
"gpu_p50_ms": 2.346,
"histogram": [
183,
1975,
392,
887,
903,
99,
12,
6,
11,
6,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 4368,
"seconds": 12.001,
"avg_fps": 363.964,
"low_1pct_fps": 147.816,
"low_01pct_fps": 99.196,
"p50_ms": 2.264,
"p95_ms": 4.763,
"p99_ms": 5.86,
"p999_ms": 7.641,
"max_ms": 11.427,
"cpu_p50_ms": 0.562,
"gpu_p50_ms": 2.203,
"histogram": [
73,
1978,
472,
695,
1004,
104,
35,
3,
1,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 4451,
"seconds": 12.001,
"avg_fps": 370.881,
"low_1pct_fps": 175.109,
"low_01pct_fps": 119.212,
"p50_ms": 2.858,
"p95_ms": 4.279,
"p99_ms": 5.045,
"p999_ms": 6.057,
"max_ms": 10.866,
"cpu_p50_ms": 0.609,
"gpu_p50_ms": 2.922,
"histogram": [
193,
1245,
964,
1732,
270,
42,
2,
0,
1,
2,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 5269,
"seconds": 12.0,
"avg_fps": 439.08,
"low_1pct_fps": 144.003,
"low_01pct_fps": 93.045,
"p50_ms": 1.767,
"p95_ms": 4.188,
"p99_ms": 5.191,
"p999_ms": 10.375,
"max_ms": 11.271,
"cpu_p50_ms": 0.545,
"gpu_p50_ms": 1.815,
"histogram": [
1239,
1550,
644,
1555,
201,
59,
1,
4,
10,
6,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 5051,
"seconds": 12.001,
"avg_fps": 420.881,
"low_1pct_fps": 164.058,
"low_01pct_fps": 123.299,
"p50_ms": 1.872,
"p95_ms": 4.455,
"p99_ms": 5.38,
"p999_ms": 6.923,
"max_ms": 11.189,
"cpu_p50_ms": 0.548,
"gpu_p50_ms": 1.874,
"histogram": [
519,
2114,
673,
1086,
577,
65,
13,
3,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 5635,
"seconds": 12.003,
"avg_fps": 469.455,
"low_1pct_fps": 184.468,
"low_01pct_fps": 106.387,
"p50_ms": 1.626,
"p95_ms": 3.874,
"p99_ms": 4.614,
"p999_ms": 5.913,
"max_ms": 18.185,
"cpu_p50_ms": 0.564,
"gpu_p50_ms": 1.604,
"histogram": [
1491,
1599,
704,
1688,
128,
20,
1,
2,
0,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 5578,
"seconds": 12.003,
"avg_fps": 464.72,
"low_1pct_fps": 141.437,
"low_01pct_fps": 76.851,
"p50_ms": 1.587,
"p95_ms": 4.127,
"p99_ms": 5.085,
"p999_ms": 10.138,
"max_ms": 21.644,
"cpu_p50_ms": 0.588,
"gpu_p50_ms": 1.625,
"histogram": [
1321,
1798,
770,
1453,
173,
41,
2,
5,
7,
6,
1,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 5576,
"seconds": 11.999,
"avg_fps": 464.707,
"low_1pct_fps": 168.226,
"low_01pct_fps": 88.692,
"p50_ms": 1.627,
"p95_ms": 4.154,
"p99_ms": 4.908,
"p999_ms": 9.555,
"max_ms": 14.784,
"cpu_p50_ms": 0.595,
"gpu_p50_ms": 1.64,
"histogram": [
1054,
2049,
837,
1377,
217,
32,
3,
0,
2,
4,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"label": "horizon-m4pro-1080p"
}
@@ -0,0 +1,68 @@
# Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro (2026-11-01)
`tools/perf_bench.py` reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with performance mods, render distances
8, 16 and 32, three games each, every game from a fresh world. **These are not player numbers:** the Mac mini was running other
work the whole time (1-minute load average 15-114 on 14 cores), and every game rendered without presenting. They are kept because
they're the first Mac reports and show how each game behaves under heavy CPU contention. The reports are the
`*-m4pro-1080p-rd*.json` files next to this page.
## Setup
- **Machine:** Mac mini, Apple M4 Pro (10 performance + 4 efficiency cores, 20-core GPU), 64 GB, macOS 27.0.1. Screen
1920x1080; the session was locked, so every game ran `offscreen` (Horizon renders into a texture; vanilla draws into a
window nobody sees).
- **Horizon:** main 1bd24e4042, `cargo build --release -p horizon-client`, Metal.
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25 (Temurin), `-Xmx2G` with the launcher's G1
flags, OpenGL 4.1 (Apple's Metal-backed driver).
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0, ImmediatelyFast 1.17.1 and
Fabric API (`tools/benchmark-deps/mods.lock.json`).
- **Order:** the games took turns inside each repeat (vanilla, mods, Horizon; then mods, Horizon, vanilla; then Horizon, vanilla,
mods), one game at a time.
## Why these numbers aren't a fair comparison
- **Load.** Load before each game: RD 8 46-82, RD 16 43-114, RD 32 15-59 (the later RD 32 games ran at 15-30). Horizon raises its
render and server threads to the user-interactive QoS class, and the JVM's threads stay at the default, so under this
contention macOS runs Horizon's frame first. Vanilla's 1% lows of 2-15 FPS show that, more than how fast either game is.
- **Resolution.** Vanilla's 1920x1080 window was shrunk to **1920x930** by the 1080p screen (menu and title bars); Horizon,
offscreen, rendered 1920x1080. Vanilla drew 14% fewer pixels, which favours it in GPU-bound scenes. Because the recorded
windows differ, `perf_bench.py --table` refuses to compare these reports. The tables below were made with the window check
skipped. A rerun at a size that fits the screen (1600x900) would avoid it.
## Results
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight (average / 1% low / 0.1% low):
| RD | scene | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- | --- |
| 8 | forest | 364 ±22 / 145 ±54 / 97 ±38 | 66 ±22 / 8 ±10 / 6 ±8 | 47 ±42 / 4 ±2 / 3 ±1 |
| 8 | mountain | 466 ±7 / 165 ±54 / 91 ±37 | 46 ±37 / 5 ±7 / 4 ±6 | 67 ±35 / 8 ±5 / 4 ±4 |
| 8 | ocean | 410 ±88 / 161 ±39 / 112 ±41 | 50 ±44 / 6 ±13 / 5 ±11 | 35 ±16 / 5 ±4 / 4 ±4 |
| 8 | village | 540 ±453 / 196 ±189 / 135 ±184 | 42 ±16 / 4 ±3 / 3 ±0 | 33 ±46 / 4 ±6 / 3 ±4 |
| 16 | forest | 333 ±4 / 193 ±29 / 137 ±71 | 23 ±9 / 2 ±1 / 2 ±1 | 62 ±94 / 10 ±21 / 7 ±14 |
| 16 | mountain | 357 ±6 / 200 ±30 / 154 ±33 | 44 ±12 / 7 ±2 / 4 ±1 | 36 ±44 / 4 ±5 / 3 ±5 |
| 16 | ocean | 412 ±4 / 209 ±26 / 127 ±44 | 51 ±16 / 6 ±8 / 4 ±7 | 75 ±159 / 13 ±33 / 7 ±16 |
| 16 | village | 568 ±30 / 271 ±25 / 210 ±75 | 46 ±110 / 9 ±27 / 7 ±17 | 46 ±45 / 7 ±9 / 5 ±4 |
| 32 | forest | 165 ±11 / 104 ±51 / 95 ±60 | 68 ±122 / 18 ±49 / 14 ±42 | 114 ±132 / 29 ±44 / 21 ±34 |
| 32 | mountain | 190 ±5 / 109 ±35 / 91 ±32 | 29 ±19 / 3 ±2 / 2 ±3 | 57 ±49 / 7 ±2 / 3 ±3 |
| 32 | ocean | 210 ±13 / 129 ±77 / 116 ±98 | 44 ±80 / 5 ±4 / 2 ±1 | 119 ±176 / 32 ±66 / 17 ±37 |
| 32 | village | 326 ±17 / 152 ±85 / 123 ±84 | 81 ±99 / 15 ±20 / 11 ±11 | 92 ±28 / 13 ±6 / 9 ±3 |
Start to title / in-world, mean scene load, RSS steady / peak, physical footprint steady / peak (macOS `footprint`, which on Apple
Silicon includes the game's GPU allocations), load average before the first game:
| RD | Horizon main | Vanilla 26.3 | Vanilla + mods |
| --- | --- | --- | --- |
| 8 | 7.2 / 11.4, 6.5, 1379 / 1537, 1883 / 2094, 73 | 49.9 / 89.1, 18.2, 1811 / 1877, 2635 / 2709, 66 | 56.3 / 97.7, 17.9, 1927 / 2019, 2396 / 2441, 74 |
| 16 | 1.5 / 14.0, 9.5, 1617 / 1892, 2495 / 3084, 114 | 19.4 / 78.9, 33.3, 2431 / 2555, 3450 / 3713, 76 | 23.8 / 76.9, 27.1, 2355 / 2566, 3212 / 3431, 70 |
| 32 | 2.2 / 14.3, 11.1, 2196 / 2537, 4373 / 5560, 28 | 20.6 / 124.2, 44.2, 2838 / 2958, 4265 / 4618, 59 | 13.5 / 63.2, 29.9, 3705 / 4078, 4647 / 5250, 37 |
## What it shows
- Horizon is GPU-bound on the M4 Pro in every scene at every distance: its median GPU time per frame is 1.6-2.5 ms at RD 8 and
2.9-5.7 ms at RD 32, its render thread's CPU time 0.6-2.2 ms.
- In the least-loaded RD 32 games (load 15-22), vanilla + mods matched Horizon's average FPS over the forest (162 vs 163-170) and
the ocean (187 vs 209-216); Horizon's 1% lows were 2.4 times the modded client's there, and 15-17 times over the village and the
mountain.
- Horizon's resident memory is 1.2-1.7 times smaller than both clients'. Its physical footprint, GPU memory included, is level
with vanilla's at RD 32 (4373 vs 4265 MB steady) and above it at peak (5560 vs 4618 MB).
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 75.854,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 19419.646,
"stdev": 10653.755,
"min": 8240.178,
"max": 29455.403,
"ci95": 26467.531
},
"startup.world_ms": {
"n": 3,
"mean": 78944.646,
"stdev": 36737.011,
"min": 51495.357,
"max": 120678.403,
"ci95": 91267.16
},
"chunks.village.load_ms": {
"n": 3,
"mean": 21947.683,
"stdev": 10432.774,
"min": 10427.451,
"max": 30758.368,
"ci95": 25918.54
},
"fps.village.avg_fps": {
"n": 3,
"mean": 45.524,
"stdev": 44.352,
"min": 15.749,
"max": 96.497,
"ci95": 110.185
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 9.141,
"stdev": 10.812,
"min": 1.86,
"max": 21.565,
"ci95": 26.862
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 6.727,
"stdev": 6.902,
"min": 1.785,
"max": 14.612,
"ci95": 17.146
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 39062.839,
"stdev": 21806.234,
"min": 15037.908,
"max": 57603.235,
"ci95": 54174.061
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 23.347,
"stdev": 3.693,
"min": 20.154,
"max": 27.392,
"ci95": 9.175
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 2.419,
"stdev": 0.269,
"min": 2.196,
"max": 2.718,
"ci95": 0.669
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 1.972,
"stdev": 0.438,
"min": 1.551,
"max": 2.425,
"ci95": 1.088
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 22097.713,
"stdev": 4463.742,
"min": 18053.319,
"max": 26887.066,
"ci95": 11089.445
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 51.279,
"stdev": 6.309,
"min": 44.786,
"max": 57.386,
"ci95": 15.673
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.15,
"stdev": 3.128,
"min": 3.444,
"max": 9.574,
"ci95": 7.77
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.161,
"stdev": 2.739,
"min": 2.487,
"max": 7.321,
"ci95": 6.803
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 50257.39,
"stdev": 34798.619,
"min": 25919.598,
"max": 90115.595,
"ci95": 86451.54
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 44.346,
"stdev": 4.882,
"min": 39.246,
"max": 48.975,
"ci95": 12.128
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 6.637,
"stdev": 0.69,
"min": 5.888,
"max": 7.246,
"ci95": 1.714
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 4.444,
"stdev": 0.6,
"min": 3.937,
"max": 5.107,
"ci95": 1.491
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2554.552,
"stdev": 218.163,
"min": 2328.859,
"max": 2764.313,
"ci95": 541.991
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2430.854,
"stdev": 230.654,
"min": 2186.328,
"max": 2644.531,
"ci95": 573.023
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3713.311,
"stdev": 191.61,
"min": 3512.331,
"max": 3893.925,
"ci95": 476.025
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3450.061,
"stdev": 76.233,
"min": 3370.253,
"max": 3522.128,
"ci95": 189.388
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 189,
"seconds": 12.001,
"avg_fps": 15.749,
"low_1pct_fps": 1.86,
"low_01pct_fps": 1.785,
"p50_ms": 22.219,
"p95_ms": 254.854,
"p99_ms": 515.134,
"p999_ms": 560.079,
"max_ms": 560.079,
"histogram": [
0,
0,
0,
1,
2,
2,
7,
9,
10,
16,
12,
11,
14,
16,
22,
9,
6,
5,
11,
10,
15,
9,
2,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 292,
"seconds": 12.004,
"avg_fps": 24.325,
"low_1pct_fps": 3.998,
"low_01pct_fps": 3.783,
"p50_ms": 15.477,
"p95_ms": 165.164,
"p99_ms": 242.898,
"p999_ms": 264.363,
"max_ms": 264.363,
"histogram": [
0,
0,
0,
0,
4,
26,
20,
32,
20,
20,
17,
12,
12,
10,
17,
14,
20,
14,
16,
16,
21,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1158,
"seconds": 12.0,
"avg_fps": 96.497,
"low_1pct_fps": 21.565,
"low_01pct_fps": 14.612,
"p50_ms": 7.567,
"p95_ms": 22.849,
"p99_ms": 34.806,
"p999_ms": 62.86,
"max_ms": 74.011,
"histogram": [
0,
0,
0,
0,
1,
38,
452,
158,
102,
94,
125,
68,
34,
42,
30,
7,
4,
2,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 271,
"seconds": 12.047,
"avg_fps": 22.496,
"low_1pct_fps": 2.196,
"low_01pct_fps": 1.551,
"p50_ms": 18.263,
"p95_ms": 189.284,
"p99_ms": 317.823,
"p999_ms": 644.675,
"max_ms": 644.675,
"histogram": [
0,
0,
0,
0,
0,
3,
22,
23,
13,
22,
22,
19,
24,
22,
14,
17,
12,
13,
9,
16,
15,
4,
1,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 242,
"seconds": 12.007,
"avg_fps": 20.154,
"low_1pct_fps": 2.343,
"low_01pct_fps": 1.939,
"p50_ms": 19.762,
"p95_ms": 157.949,
"p99_ms": 376.292,
"p999_ms": 515.678,
"max_ms": 515.678,
"histogram": [
0,
0,
0,
0,
0,
13,
17,
16,
26,
19,
11,
9,
10,
11,
9,
16,
17,
12,
21,
19,
9,
6,
1,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 331,
"seconds": 12.084,
"avg_fps": 27.392,
"low_1pct_fps": 2.718,
"low_01pct_fps": 2.425,
"p50_ms": 13.0,
"p95_ms": 162.797,
"p99_ms": 325.827,
"p999_ms": 412.349,
"max_ms": 412.349,
"histogram": [
0,
0,
0,
0,
3,
5,
26,
34,
42,
41,
24,
27,
15,
21,
22,
8,
10,
11,
10,
14,
10,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 620,
"seconds": 12.001,
"avg_fps": 51.664,
"low_1pct_fps": 3.444,
"low_01pct_fps": 2.487,
"p50_ms": 10.358,
"p95_ms": 54.808,
"p99_ms": 181.112,
"p999_ms": 402.152,
"max_ms": 402.152,
"histogram": [
0,
0,
0,
0,
0,
30,
79,
95,
86,
90,
60,
54,
28,
28,
19,
9,
8,
8,
6,
8,
7,
5,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 689,
"seconds": 12.006,
"avg_fps": 57.386,
"low_1pct_fps": 9.574,
"low_01pct_fps": 7.321,
"p50_ms": 12.76,
"p95_ms": 43.737,
"p99_ms": 69.357,
"p999_ms": 136.592,
"max_ms": 136.592,
"histogram": [
0,
0,
0,
0,
0,
23,
46,
80,
80,
92,
68,
74,
54,
49,
51,
24,
22,
16,
6,
4,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 538,
"seconds": 12.013,
"avg_fps": 44.786,
"low_1pct_fps": 5.431,
"low_01pct_fps": 2.674,
"p50_ms": 14.011,
"p95_ms": 71.23,
"p99_ms": 141.423,
"p999_ms": 373.913,
"max_ms": 373.913,
"histogram": [
0,
0,
0,
0,
0,
1,
8,
38,
84,
83,
53,
62,
52,
54,
29,
12,
15,
13,
22,
10,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 588,
"seconds": 12.006,
"avg_fps": 48.975,
"low_1pct_fps": 6.777,
"low_01pct_fps": 3.937,
"p50_ms": 12.678,
"p95_ms": 64.221,
"p99_ms": 108.513,
"p999_ms": 254.008,
"max_ms": 254.008,
"histogram": [
0,
0,
0,
0,
4,
34,
21,
41,
108,
62,
55,
55,
40,
45,
41,
21,
16,
17,
18,
9,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 471,
"seconds": 12.001,
"avg_fps": 39.246,
"low_1pct_fps": 5.888,
"low_01pct_fps": 4.289,
"p50_ms": 17.65,
"p95_ms": 73.314,
"p99_ms": 135.3,
"p999_ms": 233.136,
"max_ms": 233.136,
"histogram": [
0,
0,
0,
0,
3,
12,
22,
29,
36,
40,
43,
38,
41,
46,
62,
29,
20,
19,
22,
5,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 538,
"seconds": 12.004,
"avg_fps": 44.818,
"low_1pct_fps": 7.246,
"low_01pct_fps": 5.107,
"p50_ms": 15.959,
"p95_ms": 63.485,
"p99_ms": 109.639,
"p999_ms": 195.815,
"max_ms": 195.815,
"histogram": [
0,
0,
0,
0,
0,
4,
6,
21,
75,
56,
54,
78,
54,
69,
36,
23,
22,
18,
14,
7,
1,
0,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 58.672,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 20579.57,
"stdev": 13060.035,
"min": 10660.541,
"max": 35376.471,
"ci95": 32445.545
},
"startup.world_ms": {
"n": 3,
"mean": 124180.237,
"stdev": 102723.197,
"min": 51510.541,
"max": 241702.471,
"ci95": 255199.163
},
"chunks.village.load_ms": {
"n": 3,
"mean": 23389.087,
"stdev": 5416.249,
"min": 18538.934,
"max": 29233.632,
"ci95": 13455.795
},
"fps.village.avg_fps": {
"n": 3,
"mean": 81.421,
"stdev": 39.944,
"min": 35.32,
"max": 105.706,
"ci95": 99.234
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 15.288,
"stdev": 8.185,
"min": 5.907,
"max": 20.97,
"ci95": 20.334
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 10.544,
"stdev": 4.403,
"min": 5.804,
"max": 14.505,
"ci95": 10.938
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 48382.741,
"stdev": 32005.955,
"min": 26161.003,
"max": 85067.56,
"ci95": 79513.616
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 67.82,
"stdev": 49.16,
"min": 12.722,
"max": 107.193,
"ci95": 122.129
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 17.769,
"stdev": 19.765,
"min": 2.609,
"max": 40.124,
"ci95": 49.103
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 13.876,
"stdev": 16.851,
"min": 2.308,
"max": 33.21,
"ci95": 41.864
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 37630.797,
"stdev": 33094.177,
"min": 14901.304,
"max": 75599.155,
"ci95": 82217.128
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 44.125,
"stdev": 32.286,
"min": 13.532,
"max": 77.872,
"ci95": 80.209
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.619,
"stdev": 1.694,
"min": 3.247,
"max": 6.512,
"ci95": 4.208
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 2.354,
"stdev": 0.44,
"min": 1.967,
"max": 2.833,
"ci95": 1.094
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 67242.203,
"stdev": 17886.115,
"min": 56354.348,
"max": 87884.948,
"ci95": 44435.159
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 28.745,
"stdev": 7.77,
"min": 20.237,
"max": 35.466,
"ci95": 19.304
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.013,
"stdev": 0.849,
"min": 2.273,
"max": 3.94,
"ci95": 2.109
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 2.377,
"stdev": 1.031,
"min": 1.246,
"max": 3.263,
"ci95": 2.561
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2958.083,
"stdev": 22.425,
"min": 2937.734,
"max": 2982.125,
"ci95": 55.71
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2837.568,
"stdev": 165.159,
"min": 2648.188,
"max": 2951.719,
"ci95": 410.31
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 4618.337,
"stdev": 95.349,
"min": 4543.347,
"max": 4725.644,
"ci95": 236.878
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4264.652,
"stdev": 87.422,
"min": 4173.589,
"max": 4347.91,
"ci95": 217.185
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 424,
"seconds": 12.004,
"avg_fps": 35.32,
"low_1pct_fps": 5.907,
"low_01pct_fps": 5.804,
"p50_ms": 17.195,
"p95_ms": 95.694,
"p99_ms": 164.6,
"p999_ms": 172.292,
"max_ms": 172.292,
"histogram": [
0,
0,
0,
0,
0,
0,
5,
19,
39,
45,
44,
52,
42,
41,
38,
21,
20,
21,
19,
12,
6,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1239,
"seconds": 12.001,
"avg_fps": 103.238,
"low_1pct_fps": 20.97,
"low_01pct_fps": 14.505,
"p50_ms": 8.073,
"p95_ms": 18.618,
"p99_ms": 34.704,
"p999_ms": 53.789,
"max_ms": 84.091,
"histogram": [
0,
0,
0,
0,
0,
2,
275,
412,
260,
117,
60,
35,
25,
23,
14,
7,
5,
3,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 1271,
"seconds": 12.024,
"avg_fps": 105.706,
"low_1pct_fps": 18.988,
"low_01pct_fps": 11.324,
"p50_ms": 7.988,
"p95_ms": 16.852,
"p99_ms": 35.156,
"p999_ms": 65.46,
"max_ms": 111.155,
"histogram": [
0,
0,
0,
0,
0,
3,
220,
500,
318,
101,
43,
21,
15,
19,
17,
4,
4,
5,
0,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 154,
"seconds": 12.105,
"avg_fps": 12.722,
"low_1pct_fps": 2.609,
"low_01pct_fps": 2.308,
"p50_ms": 54.156,
"p95_ms": 216.738,
"p99_ms": 333.238,
"p999_ms": 433.205,
"max_ms": 433.205,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
4,
5,
7,
9,
13,
25,
5,
7,
12,
17,
26,
18,
5,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1287,
"seconds": 12.006,
"avg_fps": 107.193,
"low_1pct_fps": 40.124,
"low_01pct_fps": 33.21,
"p50_ms": 8.355,
"p95_ms": 14.243,
"p99_ms": 22.791,
"p999_ms": 29.023,
"max_ms": 31.2,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
627,
416,
130,
43,
33,
16,
18,
4,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1003,
"seconds": 12.005,
"avg_fps": 83.546,
"low_1pct_fps": 10.575,
"low_01pct_fps": 6.111,
"p50_ms": 9.556,
"p95_ms": 22.218,
"p99_ms": 53.64,
"p999_ms": 143.879,
"max_ms": 183.38,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
57,
515,
239,
64,
45,
21,
30,
12,
4,
4,
5,
3,
3,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 163,
"seconds": 12.045,
"avg_fps": 13.532,
"low_1pct_fps": 3.247,
"low_01pct_fps": 2.833,
"p50_ms": 52.758,
"p95_ms": 208.496,
"p99_ms": 263.074,
"p999_ms": 352.963,
"max_ms": 352.963,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
2,
4,
5,
5,
14,
23,
10,
12,
17,
30,
18,
20,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 951,
"seconds": 12.212,
"avg_fps": 77.872,
"low_1pct_fps": 6.512,
"low_01pct_fps": 1.967,
"p50_ms": 8.895,
"p95_ms": 31.377,
"p99_ms": 72.053,
"p999_ms": 508.284,
"max_ms": 508.284,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
359,
306,
104,
47,
29,
31,
16,
18,
10,
9,
10,
6,
2,
3,
0,
1,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 495,
"seconds": 12.082,
"avg_fps": 40.97,
"low_1pct_fps": 4.097,
"low_01pct_fps": 2.261,
"p50_ms": 11.995,
"p95_ms": 80.625,
"p99_ms": 146.758,
"p999_ms": 442.316,
"max_ms": 442.316,
"histogram": [
0,
0,
0,
0,
0,
0,
3,
80,
109,
56,
31,
37,
24,
30,
28,
17,
17,
27,
24,
8,
2,
2,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 243,
"seconds": 12.008,
"avg_fps": 20.237,
"low_1pct_fps": 2.827,
"low_01pct_fps": 2.623,
"p50_ms": 21.487,
"p95_ms": 177.814,
"p99_ms": 315.716,
"p999_ms": 381.289,
"max_ms": 381.289,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
5,
20,
26,
24,
32,
25,
21,
8,
19,
13,
18,
15,
10,
7,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 431,
"seconds": 12.153,
"avg_fps": 35.466,
"low_1pct_fps": 2.273,
"low_01pct_fps": 1.246,
"p50_ms": 12.232,
"p95_ms": 106.955,
"p99_ms": 249.209,
"p999_ms": 802.438,
"max_ms": 802.438,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
66,
137,
62,
41,
21,
23,
20,
3,
14,
11,
11,
10,
8,
2,
2,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 367,
"seconds": 12.02,
"avg_fps": 30.533,
"low_1pct_fps": 3.94,
"low_01pct_fps": 3.263,
"p50_ms": 21.354,
"p95_ms": 104.182,
"p99_ms": 218.448,
"p999_ms": 306.483,
"max_ms": 306.483,
"histogram": [
0,
0,
0,
0,
0,
0,
0,
0,
4,
52,
38,
32,
44,
53,
47,
20,
21,
19,
16,
15,
4,
2,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,707 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 66.477,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 49895.61,
"stdev": 37830.08,
"min": 27410.441,
"max": 93571.631,
"ci95": 93982.713
},
"startup.world_ms": {
"n": 3,
"mean": 89080.61,
"stdev": 49418.222,
"min": 59243.441,
"max": 146123.631,
"ci95": 122771.577
},
"chunks.village.load_ms": {
"n": 3,
"mean": 29280.957,
"stdev": 15620.91,
"min": 18681.538,
"max": 47219.974,
"ci95": 38807.625
},
"fps.village.avg_fps": {
"n": 3,
"mean": 42.245,
"stdev": 6.241,
"min": 36.416,
"max": 48.83,
"ci95": 15.506
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 4.137,
"stdev": 1.2,
"min": 3.03,
"max": 5.412,
"ci95": 2.981
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.891,
"stdev": 0.196,
"min": 2.777,
"max": 3.118,
"ci95": 0.488
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 16212.425,
"stdev": 1584.155,
"min": 14800.702,
"max": 17925.672,
"ci95": 3935.576
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 66.392,
"stdev": 8.735,
"min": 57.65,
"max": 75.119,
"ci95": 21.699
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 8.327,
"stdev": 3.862,
"min": 6.012,
"max": 12.786,
"ci95": 9.595
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.154,
"stdev": 3.399,
"min": 3.873,
"max": 10.06,
"ci95": 8.444
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 9054.345,
"stdev": 1795.805,
"min": 7429.882,
"max": 10982.71,
"ci95": 4461.386
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 49.891,
"stdev": 17.901,
"min": 29.637,
"max": 63.591,
"ci95": 44.472
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 6.144,
"stdev": 5.262,
"min": 2.948,
"max": 12.218,
"ci95": 13.074
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 5.254,
"stdev": 4.377,
"min": 2.603,
"max": 10.306,
"ci95": 10.874
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 18171.415,
"stdev": 5234.921,
"min": 12170.39,
"max": 21800.551,
"ci95": 13005.314
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 45.9,
"stdev": 14.757,
"min": 29.055,
"max": 56.549,
"ci95": 36.661
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 4.83,
"stdev": 2.815,
"min": 2.104,
"max": 7.727,
"ci95": 6.994
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.986,
"stdev": 2.385,
"min": 1.597,
"max": 6.366,
"ci95": 5.924
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 1877.276,
"stdev": 149.19,
"min": 1724.094,
"max": 2022.125,
"ci95": 370.639
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1810.693,
"stdev": 157.376,
"min": 1671.984,
"max": 1981.719,
"ci95": 390.975
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2708.649,
"stdev": 200.476,
"min": 2538.472,
"max": 2929.644,
"ci95": 498.051
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2635.498,
"stdev": 241.221,
"min": 2402.909,
"max": 2884.511,
"ci95": 599.274
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 586,
"seconds": 12.001,
"avg_fps": 48.83,
"low_1pct_fps": 3.03,
"low_01pct_fps": 2.779,
"p50_ms": 6.298,
"p95_ms": 102.59,
"p99_ms": 300.171,
"p999_ms": 359.883,
"max_ms": 359.883,
"histogram": [
0,
0,
30,
105,
67,
75,
58,
68,
43,
24,
14,
15,
8,
4,
8,
4,
7,
8,
18,
11,
11,
8,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 498,
"seconds": 12.003,
"avg_fps": 41.489,
"low_1pct_fps": 5.412,
"low_01pct_fps": 3.118,
"p50_ms": 14.331,
"p95_ms": 81.666,
"p99_ms": 133.706,
"p999_ms": 320.73,
"max_ms": 320.73,
"histogram": [
0,
0,
0,
5,
15,
23,
21,
37,
43,
41,
55,
45,
50,
45,
30,
15,
18,
18,
21,
13,
2,
1,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 437,
"seconds": 12.0,
"avg_fps": 36.416,
"low_1pct_fps": 3.97,
"low_01pct_fps": 2.777,
"p50_ms": 12.279,
"p95_ms": 133.385,
"p99_ms": 203.287,
"p999_ms": 360.113,
"max_ms": 360.113,
"histogram": [
0,
0,
0,
9,
28,
21,
19,
41,
52,
41,
31,
33,
27,
25,
27,
13,
13,
17,
12,
14,
12,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 797,
"seconds": 12.002,
"avg_fps": 66.407,
"low_1pct_fps": 6.184,
"low_01pct_fps": 4.528,
"p50_ms": 7.208,
"p95_ms": 64.408,
"p99_ms": 126.005,
"p999_ms": 220.846,
"max_ms": 220.846,
"histogram": [
0,
0,
2,
22,
78,
137,
123,
136,
77,
58,
31,
14,
13,
14,
13,
10,
11,
21,
20,
13,
4,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 692,
"seconds": 12.003,
"avg_fps": 57.65,
"low_1pct_fps": 6.012,
"low_01pct_fps": 3.873,
"p50_ms": 10.783,
"p95_ms": 48.101,
"p99_ms": 121.963,
"p999_ms": 258.167,
"max_ms": 258.167,
"histogram": [
0,
0,
0,
13,
52,
52,
41,
62,
83,
88,
50,
46,
40,
50,
43,
20,
23,
9,
11,
6,
2,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 902,
"seconds": 12.008,
"avg_fps": 75.119,
"low_1pct_fps": 12.786,
"low_01pct_fps": 10.06,
"p50_ms": 9.096,
"p95_ms": 38.051,
"p99_ms": 67.18,
"p999_ms": 99.405,
"max_ms": 99.405,
"histogram": [
0,
0,
0,
26,
115,
72,
65,
100,
118,
90,
66,
51,
54,
50,
39,
16,
17,
13,
10,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 687,
"seconds": 12.171,
"avg_fps": 56.446,
"low_1pct_fps": 3.267,
"low_01pct_fps": 2.853,
"p50_ms": 4.938,
"p95_ms": 103.639,
"p99_ms": 257.27,
"p999_ms": 350.504,
"max_ms": 350.504,
"histogram": [
0,
0,
28,
199,
125,
99,
51,
35,
32,
18,
5,
7,
11,
10,
11,
6,
2,
4,
7,
14,
15,
8,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 764,
"seconds": 12.014,
"avg_fps": 63.591,
"low_1pct_fps": 12.218,
"low_01pct_fps": 10.306,
"p50_ms": 11.549,
"p95_ms": 45.214,
"p99_ms": 67.493,
"p999_ms": 97.034,
"max_ms": 97.034,
"histogram": [
0,
0,
0,
9,
45,
41,
59,
58,
87,
101,
71,
85,
42,
56,
49,
15,
16,
22,
8,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 357,
"seconds": 12.046,
"avg_fps": 29.637,
"low_1pct_fps": 2.948,
"low_01pct_fps": 2.603,
"p50_ms": 10.947,
"p95_ms": 173.526,
"p99_ms": 311.134,
"p999_ms": 384.135,
"max_ms": 384.135,
"histogram": [
0,
0,
0,
11,
47,
31,
12,
34,
32,
23,
15,
26,
21,
14,
15,
7,
6,
16,
18,
6,
15,
8,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 355,
"seconds": 12.218,
"avg_fps": 29.055,
"low_1pct_fps": 2.104,
"low_01pct_fps": 1.597,
"p50_ms": 7.766,
"p95_ms": 145.251,
"p99_ms": 368.51,
"p999_ms": 626.205,
"max_ms": 626.205,
"histogram": [
0,
0,
41,
53,
25,
28,
15,
22,
7,
16,
10,
15,
17,
12,
15,
11,
11,
8,
14,
19,
7,
7,
2,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 680,
"seconds": 12.025,
"avg_fps": 56.549,
"low_1pct_fps": 7.727,
"low_01pct_fps": 6.366,
"p50_ms": 11.633,
"p95_ms": 52.508,
"p99_ms": 111.848,
"p999_ms": 157.09,
"max_ms": 157.09,
"histogram": [
0,
0,
1,
32,
38,
43,
49,
71,
55,
55,
54,
57,
53,
41,
54,
21,
19,
17,
12,
7,
1,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 632,
"seconds": 12.132,
"avg_fps": 52.095,
"low_1pct_fps": 4.66,
"low_01pct_fps": 3.995,
"p50_ms": 8.601,
"p95_ms": 78.611,
"p99_ms": 172.405,
"p999_ms": 250.342,
"max_ms": 250.342,
"histogram": [
0,
0,
9,
75,
86,
53,
32,
57,
40,
41,
30,
38,
25,
24,
39,
16,
23,
7,
12,
15,
9,
1,
0,
0
]
}
],
"label": "vanilla-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 16,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 69.878,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 23843.039,
"stdev": 7524.513,
"min": 16643.766,
"max": 31655.294,
"ci95": 18693.436
},
"startup.world_ms": {
"n": 3,
"mean": 76908.706,
"stdev": 36719.804,
"min": 45051.766,
"max": 117069.294,
"ci95": 91224.412
},
"chunks.village.load_ms": {
"n": 3,
"mean": 19608.485,
"stdev": 9449.035,
"min": 13510.149,
"max": 30492.958,
"ci95": 23474.6
},
"fps.village.avg_fps": {
"n": 3,
"mean": 46.159,
"stdev": 17.954,
"min": 31.413,
"max": 66.152,
"ci95": 44.604
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 6.544,
"stdev": 3.452,
"min": 3.225,
"max": 10.116,
"ci95": 8.577
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 4.689,
"stdev": 1.808,
"min": 2.647,
"max": 6.085,
"ci95": 4.491
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 23344.685,
"stdev": 5493.145,
"min": 17038.709,
"max": 27089.821,
"ci95": 13646.83
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 61.892,
"stdev": 37.939,
"min": 22.354,
"max": 98.0,
"ci95": 94.254
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 9.736,
"stdev": 8.597,
"min": 2.296,
"max": 19.148,
"ci95": 21.359
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 6.651,
"stdev": 5.574,
"min": 2.202,
"max": 12.903,
"ci95": 13.847
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 27267.052,
"stdev": 21587.34,
"min": 10640.097,
"max": 51663.797,
"ci95": 53630.253
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 75.493,
"stdev": 63.885,
"min": 25.975,
"max": 147.604,
"ci95": 158.711
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 13.396,
"stdev": 13.366,
"min": 4.634,
"max": 28.78,
"ci95": 33.205
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 7.356,
"stdev": 6.609,
"min": 3.26,
"max": 14.98,
"ci95": 16.418
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 38284.867,
"stdev": 4237.4,
"min": 33922.931,
"max": 42385.65,
"ci95": 10527.134
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 35.918,
"stdev": 17.742,
"min": 18.135,
"max": 53.619,
"ci95": 44.077
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 3.999,
"stdev": 2.132,
"min": 2.453,
"max": 6.431,
"ci95": 5.297
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.017,
"stdev": 1.831,
"min": 1.775,
"max": 5.12,
"ci95": 4.549
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2566.339,
"stdev": 218.228,
"min": 2339.094,
"max": 2774.266,
"ci95": 542.153
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 2354.852,
"stdev": 240.105,
"min": 2082.984,
"max": 2537.867,
"ci95": 596.503
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 3430.598,
"stdev": 77.063,
"min": 3345.395,
"max": 3495.426,
"ci95": 191.451
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 3211.931,
"stdev": 99.984,
"min": 3124.098,
"max": 3320.738,
"ci95": 248.394
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 377,
"seconds": 12.002,
"avg_fps": 31.413,
"low_1pct_fps": 3.225,
"low_01pct_fps": 2.647,
"p50_ms": 15.145,
"p95_ms": 128.716,
"p99_ms": 276.685,
"p999_ms": 377.749,
"max_ms": 377.749,
"histogram": [
0,
0,
0,
0,
17,
26,
21,
30,
23,
43,
16,
25,
28,
28,
35,
16,
14,
12,
15,
13,
11,
4,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 794,
"seconds": 12.003,
"avg_fps": 66.152,
"low_1pct_fps": 10.116,
"low_01pct_fps": 6.085,
"p50_ms": 11.137,
"p95_ms": 41.078,
"p99_ms": 73.531,
"p999_ms": 164.347,
"max_ms": 164.347,
"histogram": [
0,
0,
0,
20,
57,
65,
52,
70,
82,
88,
80,
67,
53,
48,
52,
18,
20,
10,
10,
1,
1,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 491,
"seconds": 12.001,
"avg_fps": 40.912,
"low_1pct_fps": 6.291,
"low_01pct_fps": 5.336,
"p50_ms": 15.962,
"p95_ms": 71.604,
"p99_ms": 137.056,
"p999_ms": 187.401,
"max_ms": 187.401,
"histogram": [
0,
0,
0,
0,
1,
5,
10,
32,
54,
60,
45,
49,
44,
49,
50,
22,
21,
20,
18,
8,
3,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 784,
"seconds": 12.002,
"avg_fps": 65.322,
"low_1pct_fps": 7.763,
"low_01pct_fps": 4.848,
"p50_ms": 10.024,
"p95_ms": 43.46,
"p99_ms": 94.19,
"p999_ms": 206.286,
"max_ms": 206.286,
"histogram": [
0,
0,
0,
17,
40,
69,
80,
95,
88,
82,
53,
58,
39,
49,
52,
21,
15,
14,
7,
3,
2,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1176,
"seconds": 12.0,
"avg_fps": 98.0,
"low_1pct_fps": 19.148,
"low_01pct_fps": 12.903,
"p50_ms": 6.214,
"p95_ms": 30.125,
"p99_ms": 41.199,
"p999_ms": 73.675,
"max_ms": 81.328,
"histogram": [
0,
0,
3,
179,
205,
175,
115,
111,
79,
39,
21,
25,
34,
79,
77,
20,
10,
2,
2,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 271,
"seconds": 12.123,
"avg_fps": 22.354,
"low_1pct_fps": 2.296,
"low_01pct_fps": 2.202,
"p50_ms": 14.989,
"p95_ms": 214.282,
"p99_ms": 404.5,
"p999_ms": 454.134,
"max_ms": 454.134,
"histogram": [
0,
0,
0,
1,
5,
18,
19,
19,
28,
28,
13,
19,
20,
12,
21,
14,
5,
8,
4,
9,
18,
10,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 1772,
"seconds": 12.005,
"avg_fps": 147.604,
"low_1pct_fps": 28.78,
"low_01pct_fps": 14.98,
"p50_ms": 5.328,
"p95_ms": 15.261,
"p99_ms": 25.961,
"p999_ms": 58.586,
"max_ms": 74.925,
"histogram": [
0,
0,
34,
421,
331,
290,
175,
163,
122,
83,
44,
33,
26,
28,
16,
2,
2,
1,
1,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 635,
"seconds": 12.004,
"avg_fps": 52.9,
"low_1pct_fps": 6.774,
"low_01pct_fps": 3.828,
"p50_ms": 9.578,
"p95_ms": 61.195,
"p99_ms": 112.118,
"p999_ms": 261.228,
"max_ms": 261.228,
"histogram": [
0,
0,
0,
38,
49,
64,
63,
63,
51,
60,
27,
29,
27,
17,
37,
28,
30,
27,
15,
8,
1,
1,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 312,
"seconds": 12.011,
"avg_fps": 25.975,
"low_1pct_fps": 4.634,
"low_01pct_fps": 3.26,
"p50_ms": 24.629,
"p95_ms": 115.493,
"p99_ms": 169.827,
"p999_ms": 306.737,
"max_ms": 306.737,
"histogram": [
0,
0,
0,
0,
0,
0,
2,
5,
10,
10,
24,
39,
33,
38,
47,
16,
16,
21,
23,
22,
5,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 218,
"seconds": 12.021,
"avg_fps": 18.135,
"low_1pct_fps": 2.453,
"low_01pct_fps": 2.156,
"p50_ms": 17.447,
"p95_ms": 257.129,
"p99_ms": 377.63,
"p999_ms": 463.802,
"max_ms": 463.802,
"histogram": [
0,
0,
7,
13,
15,
18,
11,
9,
7,
16,
8,
3,
11,
10,
14,
7,
5,
16,
8,
15,
12,
13,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 653,
"seconds": 12.179,
"avg_fps": 53.619,
"low_1pct_fps": 6.431,
"low_01pct_fps": 5.12,
"p50_ms": 8.427,
"p95_ms": 58.968,
"p99_ms": 141.423,
"p999_ms": 195.302,
"max_ms": 195.302,
"histogram": [
0,
0,
0,
38,
66,
76,
79,
64,
45,
38,
20,
29,
22,
24,
46,
25,
36,
17,
14,
10,
4,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 432,
"seconds": 12.0,
"avg_fps": 35.999,
"low_1pct_fps": 3.112,
"low_01pct_fps": 1.775,
"p50_ms": 16.724,
"p95_ms": 83.13,
"p99_ms": 220.496,
"p999_ms": 563.493,
"max_ms": 563.493,
"histogram": [
0,
0,
0,
7,
14,
17,
23,
35,
35,
26,
24,
33,
34,
43,
43,
23,
27,
20,
15,
5,
4,
3,
1,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T23:17:23Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 32,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 37.185,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 13521.661,
"stdev": 573.908,
"min": 13085.942,
"max": 14171.935,
"ci95": 1425.783
},
"startup.world_ms": {
"n": 3,
"mean": 63229.994,
"stdev": 6185.388,
"min": 56237.942,
"max": 67987.935,
"ci95": 15366.595
},
"chunks.village.load_ms": {
"n": 3,
"mean": 14807.746,
"stdev": 2020.358,
"min": 13414.414,
"max": 17124.849,
"ci95": 5019.253
},
"fps.village.avg_fps": {
"n": 3,
"mean": 91.753,
"stdev": 11.452,
"min": 78.555,
"max": 99.067,
"ci95": 28.451
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 13.105,
"stdev": 2.514,
"min": 10.354,
"max": 15.282,
"ci95": 6.244
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 9.203,
"stdev": 1.382,
"min": 7.689,
"max": 10.396,
"ci95": 3.432
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 24180.167,
"stdev": 777.711,
"min": 23290.973,
"max": 24733.555,
"ci95": 1932.098
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 113.871,
"stdev": 53.305,
"min": 56.542,
"max": 161.938,
"ci95": 132.428
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 28.748,
"stdev": 17.633,
"min": 12.823,
"max": 47.698,
"ci95": 43.807
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 20.551,
"stdev": 13.547,
"min": 8.937,
"max": 35.433,
"ci95": 33.655
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 19314.547,
"stdev": 5853.258,
"min": 15068.272,
"max": 25991.528,
"ci95": 14541.473
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 119.116,
"stdev": 71.027,
"min": 45.498,
"max": 187.231,
"ci95": 176.454
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 31.721,
"stdev": 26.624,
"min": 8.585,
"max": 60.822,
"ci95": 66.144
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 16.8,
"stdev": 14.937,
"min": 5.876,
"max": 33.821,
"ci95": 37.109
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 61290.415,
"stdev": 15063.343,
"min": 49453.256,
"max": 78246.003,
"ci95": 37422.439
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 56.905,
"stdev": 19.674,
"min": 37.807,
"max": 77.109,
"ci95": 48.878
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.384,
"stdev": 0.62,
"min": 6.67,
"max": 7.784,
"ci95": 1.539
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.48,
"stdev": 1.306,
"min": 2.526,
"max": 4.968,
"ci95": 3.244
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 4078.01,
"stdev": 79.593,
"min": 4020.391,
"max": 4168.828,
"ci95": 197.735
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 3705.104,
"stdev": 149.415,
"min": 3541.375,
"max": 3834.078,
"ci95": 371.196
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 5250.386,
"stdev": 54.229,
"min": 5200.662,
"max": 5308.208,
"ci95": 134.723
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 4647.172,
"stdev": 29.179,
"min": 4613.552,
"max": 4665.911,
"ci95": 72.492
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 1172,
"seconds": 12.004,
"avg_fps": 97.637,
"low_1pct_fps": 15.282,
"low_01pct_fps": 10.396,
"p50_ms": 7.394,
"p95_ms": 26.126,
"p99_ms": 50.505,
"p999_ms": 79.614,
"max_ms": 112.764,
"histogram": [
0,
0,
0,
24,
111,
198,
193,
170,
129,
109,
59,
42,
43,
23,
38,
10,
10,
7,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 1189,
"seconds": 12.002,
"avg_fps": 99.067,
"low_1pct_fps": 13.678,
"low_01pct_fps": 9.523,
"p50_ms": 7.061,
"p95_ms": 28.029,
"p99_ms": 58.166,
"p999_ms": 103.326,
"max_ms": 106.682,
"histogram": [
0,
0,
0,
59,
132,
223,
172,
171,
107,
90,
59,
39,
36,
28,
35,
14,
9,
9,
4,
2,
0,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 943,
"seconds": 12.004,
"avg_fps": 78.555,
"low_1pct_fps": 10.354,
"low_01pct_fps": 7.689,
"p50_ms": 8.817,
"p95_ms": 31.62,
"p99_ms": 70.58,
"p999_ms": 130.049,
"max_ms": 130.049,
"histogram": [
0,
0,
0,
8,
34,
97,
118,
168,
126,
106,
69,
58,
49,
36,
32,
8,
9,
13,
8,
4,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 679,
"seconds": 12.009,
"avg_fps": 56.542,
"low_1pct_fps": 12.823,
"low_01pct_fps": 8.937,
"p50_ms": 14.344,
"p95_ms": 42.09,
"p99_ms": 65.756,
"p999_ms": 111.897,
"max_ms": 111.897,
"histogram": [
0,
0,
0,
0,
2,
33,
50,
62,
68,
60,
48,
67,
84,
76,
64,
27,
18,
14,
5,
1,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 1478,
"seconds": 12.003,
"avg_fps": 123.133,
"low_1pct_fps": 25.723,
"low_01pct_fps": 17.282,
"p50_ms": 6.388,
"p95_ms": 17.972,
"p99_ms": 29.029,
"p999_ms": 57.659,
"max_ms": 58.069,
"histogram": [
0,
0,
0,
3,
336,
298,
222,
186,
144,
77,
70,
57,
39,
23,
13,
5,
3,
2,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 1944,
"seconds": 12.005,
"avg_fps": 161.938,
"low_1pct_fps": 47.698,
"low_01pct_fps": 35.433,
"p50_ms": 5.168,
"p95_ms": 12.12,
"p99_ms": 17.337,
"p999_ms": 26.48,
"max_ms": 29.965,
"histogram": [
0,
0,
0,
18,
793,
527,
243,
125,
91,
47,
41,
34,
15,
7,
3,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 548,
"seconds": 12.044,
"avg_fps": 45.498,
"low_1pct_fps": 8.585,
"low_01pct_fps": 5.876,
"p50_ms": 15.416,
"p95_ms": 62.523,
"p99_ms": 98.757,
"p999_ms": 170.17,
"max_ms": 170.17,
"histogram": [
0,
0,
0,
0,
2,
15,
26,
52,
53,
58,
37,
56,
39,
58,
60,
27,
24,
18,
21,
1,
1,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 1496,
"seconds": 12.005,
"avg_fps": 124.619,
"low_1pct_fps": 25.756,
"low_01pct_fps": 10.702,
"p50_ms": 6.649,
"p95_ms": 15.751,
"p99_ms": 26.208,
"p999_ms": 76.546,
"max_ms": 110.33,
"histogram": [
0,
0,
0,
15,
250,
299,
266,
216,
163,
115,
68,
40,
32,
13,
14,
3,
0,
0,
1,
1,
0,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 2247,
"seconds": 12.001,
"avg_fps": 187.231,
"low_1pct_fps": 60.822,
"low_01pct_fps": 33.821,
"p50_ms": 4.73,
"p95_ms": 8.497,
"p99_ms": 12.315,
"p999_ms": 21.724,
"max_ms": 42.26,
"histogram": [
0,
0,
0,
347,
987,
453,
211,
122,
67,
32,
15,
6,
4,
2,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 670,
"seconds": 12.007,
"avg_fps": 55.799,
"low_1pct_fps": 7.784,
"low_01pct_fps": 2.526,
"p50_ms": 12.887,
"p95_ms": 45.129,
"p99_ms": 76.315,
"p999_ms": 395.821,
"max_ms": 395.821,
"histogram": [
0,
0,
0,
0,
0,
16,
54,
79,
75,
81,
68,
60,
53,
70,
54,
17,
16,
9,
17,
0,
0,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 455,
"seconds": 12.035,
"avg_fps": 37.807,
"low_1pct_fps": 6.67,
"low_01pct_fps": 4.968,
"p50_ms": 15.986,
"p95_ms": 82.012,
"p99_ms": 117.987,
"p999_ms": 201.279,
"max_ms": 201.279,
"histogram": [
0,
0,
0,
0,
0,
3,
27,
37,
47,
49,
37,
39,
31,
39,
34,
20,
24,
30,
26,
10,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 933,
"seconds": 12.1,
"avg_fps": 77.109,
"low_1pct_fps": 7.697,
"low_01pct_fps": 2.946,
"p50_ms": 8.593,
"p95_ms": 34.696,
"p99_ms": 79.266,
"p999_ms": 339.486,
"max_ms": 339.486,
"histogram": [
0,
0,
0,
0,
0,
45,
176,
209,
196,
95,
45,
43,
26,
19,
30,
9,
9,
14,
13,
2,
1,
1,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
@@ -0,0 +1,723 @@
{
"format": "horizon-benchmark-report",
"version": 1,
"kind": "game",
"created": "2026-11-01T22:55:52Z",
"app": {
"name": "minecraft",
"version": "26.3",
"mods": [
{
"name": "fabric-loader",
"version": "0.19.5"
},
{
"name": "sodium",
"version": "mc26.3-0.9.2-fabric"
},
{
"name": "lithium",
"version": "mc26.3-0.26.2-fabric"
},
{
"name": "ferrite-core",
"version": "9.0.0-fabric"
},
{
"name": "immediatelyfast",
"version": "1.17.1+26.3-fabric"
}
]
},
"hardware": {
"os": "macos",
"arch": "aarch64",
"cpu_threads": 14,
"os_version": "27.0.1",
"cpu": "Apple M4 Pro",
"ram_mb": 65536,
"gpu": {
"name": "Apple M4 Pro",
"vendor": "Apple",
"driver": "4.1 Metal - 91.7",
"backend": "opengl",
"kind": ""
}
},
"conditions": {
"scenario": "perf_bench:village,forest,ocean,mountain",
"seed": "1234",
"render_distance": 8,
"simulation_distance": 12,
"vsync": false,
"offscreen": true,
"repeats": 3,
"warmup_s": 2.0,
"load_avg_1m": 74.321,
"window": [
1920,
930
]
},
"metrics": {
"startup.title_ms": {
"n": 3,
"mean": 56311.461,
"stdev": 26982.658,
"min": 36031.835,
"max": 86935.815,
"ci95": 67034.048
},
"startup.world_ms": {
"n": 3,
"mean": 97731.794,
"stdev": 21691.925,
"min": 72951.835,
"max": 113284.815,
"ci95": 53890.078
},
"chunks.village.load_ms": {
"n": 3,
"mean": 13954.335,
"stdev": 10146.301,
"min": 7679.006,
"max": 25660.115,
"ci95": 25206.843
},
"fps.village.avg_fps": {
"n": 3,
"mean": 33.239,
"stdev": 18.526,
"min": 12.03,
"max": 46.264,
"ci95": 46.026
},
"fps.village.low_1pct_fps": {
"n": 3,
"mean": 3.772,
"stdev": 2.399,
"min": 1.734,
"max": 6.416,
"ci95": 5.96
},
"fps.village.low_01pct_fps": {
"n": 3,
"mean": 2.866,
"stdev": 1.762,
"min": 1.433,
"max": 4.834,
"ci95": 4.379
},
"chunks.forest.load_ms": {
"n": 3,
"mean": 19803.961,
"stdev": 14910.114,
"min": 8220.752,
"max": 36626.588,
"ci95": 37041.766
},
"fps.forest.avg_fps": {
"n": 3,
"mean": 47.303,
"stdev": 17.044,
"min": 32.698,
"max": 66.03,
"ci95": 42.343
},
"fps.forest.low_1pct_fps": {
"n": 3,
"mean": 4.365,
"stdev": 0.709,
"min": 3.658,
"max": 5.076,
"ci95": 1.761
},
"fps.forest.low_01pct_fps": {
"n": 3,
"mean": 3.128,
"stdev": 0.564,
"min": 2.651,
"max": 3.751,
"ci95": 1.402
},
"chunks.ocean.load_ms": {
"n": 3,
"mean": 18639.188,
"stdev": 8912.103,
"min": 11960.198,
"max": 28758.72,
"ci95": 22140.678
},
"fps.ocean.avg_fps": {
"n": 3,
"mean": 34.908,
"stdev": 6.372,
"min": 29.271,
"max": 41.822,
"ci95": 15.831
},
"fps.ocean.low_1pct_fps": {
"n": 3,
"mean": 4.614,
"stdev": 1.558,
"min": 2.986,
"max": 6.09,
"ci95": 3.869
},
"fps.ocean.low_01pct_fps": {
"n": 3,
"mean": 4.06,
"stdev": 1.662,
"min": 2.224,
"max": 5.463,
"ci95": 4.13
},
"chunks.mountain.load_ms": {
"n": 3,
"mean": 19191.558,
"stdev": 2902.338,
"min": 15845.729,
"max": 21030.7,
"ci95": 7210.388
},
"fps.mountain.avg_fps": {
"n": 3,
"mean": 67.004,
"stdev": 13.923,
"min": 51.98,
"max": 79.471,
"ci95": 34.589
},
"fps.mountain.low_1pct_fps": {
"n": 3,
"mean": 7.862,
"stdev": 1.987,
"min": 6.339,
"max": 10.109,
"ci95": 4.936
},
"fps.mountain.low_01pct_fps": {
"n": 3,
"mean": 3.993,
"stdev": 1.679,
"min": 2.06,
"max": 5.088,
"ci95": 4.172
},
"memory.rss_peak_mb": {
"n": 3,
"mean": 2019.089,
"stdev": 53.398,
"min": 1960.188,
"max": 2064.328,
"ci95": 132.657
},
"memory.rss_steady_mb": {
"n": 3,
"mean": 1927.195,
"stdev": 108.077,
"min": 1813.609,
"max": 2028.758,
"ci95": 268.5
},
"memory.footprint_peak_mb": {
"n": 3,
"mean": 2440.727,
"stdev": 72.508,
"min": 2375.394,
"max": 2518.738,
"ci95": 180.134
},
"memory.footprint_steady_mb": {
"n": 3,
"mean": 2395.53,
"stdev": 71.597,
"min": 2318.504,
"max": 2460.05,
"ci95": 177.872
}
},
"frames": [
{
"phase": "village",
"repeat": 0,
"frames": 146,
"seconds": 12.136,
"avg_fps": 12.03,
"low_1pct_fps": 1.734,
"low_01pct_fps": 1.433,
"p50_ms": 28.465,
"p95_ms": 339.625,
"p99_ms": 455.663,
"p999_ms": 697.859,
"max_ms": 697.859,
"histogram": [
0,
0,
1,
4,
9,
9,
5,
5,
8,
6,
5,
5,
1,
10,
9,
6,
11,
10,
5,
8,
15,
13,
1,
0
]
},
{
"phase": "village",
"repeat": 1,
"frames": 556,
"seconds": 12.018,
"avg_fps": 46.264,
"low_1pct_fps": 6.416,
"low_01pct_fps": 4.834,
"p50_ms": 13.72,
"p95_ms": 71.774,
"p99_ms": 120.078,
"p999_ms": 206.87,
"max_ms": 206.87,
"histogram": [
0,
0,
0,
6,
5,
16,
22,
57,
55,
64,
62,
62,
50,
38,
29,
19,
23,
16,
21,
9,
2,
0,
0,
0
]
},
{
"phase": "village",
"repeat": 2,
"frames": 498,
"seconds": 12.022,
"avg_fps": 41.423,
"low_1pct_fps": 3.166,
"low_01pct_fps": 2.331,
"p50_ms": 7.7,
"p95_ms": 114.025,
"p99_ms": 246.232,
"p999_ms": 428.98,
"max_ms": 428.98,
"histogram": [
0,
0,
15,
63,
55,
58,
33,
38,
31,
22,
23,
17,
24,
24,
20,
11,
9,
10,
14,
15,
12,
4,
0,
0
]
},
{
"phase": "forest",
"repeat": 0,
"frames": 521,
"seconds": 12.065,
"avg_fps": 43.182,
"low_1pct_fps": 4.362,
"low_01pct_fps": 3.751,
"p50_ms": 8.121,
"p95_ms": 110.621,
"p99_ms": 191.925,
"p999_ms": 266.575,
"max_ms": 266.575,
"histogram": [
0,
0,
1,
63,
27,
49,
61,
67,
39,
45,
27,
17,
13,
17,
7,
11,
13,
11,
18,
22,
11,
2,
0,
0
]
},
{
"phase": "forest",
"repeat": 1,
"frames": 793,
"seconds": 12.01,
"avg_fps": 66.03,
"low_1pct_fps": 5.076,
"low_01pct_fps": 2.651,
"p50_ms": 8.218,
"p95_ms": 44.767,
"p99_ms": 143.139,
"p999_ms": 377.207,
"max_ms": 377.207,
"histogram": [
0,
0,
0,
60,
106,
55,
58,
127,
97,
64,
41,
28,
32,
31,
39,
12,
7,
8,
14,
9,
4,
1,
0,
0
]
},
{
"phase": "forest",
"repeat": 2,
"frames": 395,
"seconds": 12.08,
"avg_fps": 32.698,
"low_1pct_fps": 3.658,
"low_01pct_fps": 2.983,
"p50_ms": 10.283,
"p95_ms": 171.818,
"p99_ms": 231.998,
"p999_ms": 335.242,
"max_ms": 335.242,
"histogram": [
0,
0,
6,
19,
13,
27,
42,
31,
53,
36,
22,
25,
20,
19,
10,
8,
7,
6,
11,
15,
23,
2,
0,
0
]
},
{
"phase": "ocean",
"repeat": 0,
"frames": 405,
"seconds": 12.043,
"avg_fps": 33.63,
"low_1pct_fps": 4.765,
"low_01pct_fps": 4.492,
"p50_ms": 14.686,
"p95_ms": 115.893,
"p99_ms": 196.822,
"p999_ms": 222.615,
"max_ms": 222.615,
"histogram": [
0,
0,
0,
5,
6,
16,
18,
49,
42,
36,
22,
30,
35,
32,
23,
10,
10,
22,
22,
16,
11,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 1,
"frames": 502,
"seconds": 12.003,
"avg_fps": 41.822,
"low_1pct_fps": 6.09,
"low_01pct_fps": 5.463,
"p50_ms": 15.264,
"p95_ms": 76.822,
"p99_ms": 144.7,
"p999_ms": 183.045,
"max_ms": 183.045,
"histogram": [
0,
0,
0,
9,
14,
12,
13,
47,
43,
52,
41,
40,
52,
40,
50,
15,
26,
17,
17,
9,
5,
0,
0,
0
]
},
{
"phase": "ocean",
"repeat": 2,
"frames": 353,
"seconds": 12.06,
"avg_fps": 29.271,
"low_1pct_fps": 2.986,
"low_01pct_fps": 2.224,
"p50_ms": 10.806,
"p95_ms": 150.281,
"p99_ms": 268.607,
"p999_ms": 449.56,
"max_ms": 449.56,
"histogram": [
0,
0,
0,
29,
16,
24,
36,
35,
23,
31,
13,
16,
12,
18,
14,
9,
8,
16,
18,
17,
12,
6,
0,
0
]
},
{
"phase": "mountain",
"repeat": 0,
"frames": 624,
"seconds": 12.005,
"avg_fps": 51.98,
"low_1pct_fps": 7.137,
"low_01pct_fps": 5.088,
"p50_ms": 12.033,
"p95_ms": 75.22,
"p99_ms": 115.345,
"p999_ms": 196.553,
"max_ms": 196.553,
"histogram": [
0,
0,
4,
29,
48,
34,
38,
40,
61,
56,
50,
57,
54,
41,
33,
13,
15,
15,
28,
6,
2,
0,
0,
0
]
},
{
"phase": "mountain",
"repeat": 1,
"frames": 835,
"seconds": 12.004,
"avg_fps": 69.562,
"low_1pct_fps": 6.339,
"low_01pct_fps": 2.06,
"p50_ms": 9.818,
"p95_ms": 37.917,
"p99_ms": 81.491,
"p999_ms": 485.481,
"max_ms": 485.481,
"histogram": [
0,
0,
6,
62,
63,
59,
75,
84,
75,
94,
59,
77,
55,
31,
36,
27,
10,
10,
5,
5,
1,
1,
0,
0
]
},
{
"phase": "mountain",
"repeat": 2,
"frames": 954,
"seconds": 12.004,
"avg_fps": 79.471,
"low_1pct_fps": 10.109,
"low_01pct_fps": 4.832,
"p50_ms": 7.91,
"p95_ms": 38.672,
"p99_ms": 79.104,
"p999_ms": 206.935,
"max_ms": 206.935,
"histogram": [
0,
0,
13,
55,
70,
136,
119,
109,
93,
98,
52,
49,
48,
21,
30,
17,
14,
11,
17,
1,
1,
0,
0,
0
]
}
],
"label": "vanilla-mods-m4pro-1080p"
}
+95 -2
View File
@@ -7,7 +7,7 @@ use crate::{
json::Result,
math::{Mat4, quaternion_xyz},
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
resolve::ModelLibrary,
resolve::{ModelLibrary, ResolvedCuboidModel},
};
use std::{collections::BTreeSet, f32::consts::PI};
@@ -110,6 +110,15 @@ pub fn bake_item_model(
let model = library
.resolve(name)
.or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?;
bake_resolved(&model, sprites)
}
/// Bake an already resolved model, e.g. one from
/// [`ModelLibrary::resolve_with_textures`].
pub fn bake_resolved(
model: &ResolvedCuboidModel,
sprites: &dyn SpriteLookup,
) -> Result<BakedItemModel> {
let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some();
let material = |m: &Material| {
if known(&m.sprite) {
@@ -143,7 +152,7 @@ pub fn bake_item_model(
quads
} else {
let mut quads =
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?;
bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?;
for q in &mut quads {
q.material = material(&q.material);
}
@@ -298,3 +307,87 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result<V
}
Ok(quads)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CuboidModel, TextureSlot};
use std::collections::BTreeMap;
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
/// shows whose mask it extruded.
struct Sprites;
impl SpriteLookup for Sprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
match sprite {
"minecraft:item/stick" => Some(SpriteMask {
width: 2,
height: 2,
frames: vec![vec![true, false, false, false]],
}),
"horizon_cit:item/cit" => Some(SpriteMask {
width: 1,
height: 1,
frames: vec![vec![false]],
}),
_ => None,
}
}
}
fn library() -> ModelLibrary {
ModelLibrary::with_item_generator(BTreeMap::from(
[
("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#),
("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#),
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##),
]
.map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())),
))
.unwrap()
}
#[test]
fn baking_by_name_falls_back_to_the_missing_model() {
let library = library();
// Front, back, and the 8 unit edges around the L of 3 opaque pixels.
let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap();
assert_eq!(stick.quads.len(), 10);
// An unknown sprite bakes as the missing sprite.
let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap();
assert_eq!(slab.quads.len(), 2);
assert_eq!(slab.particle.sprite, MISSING_SPRITE);
// A cyclic chain, an error to `resolve`, bakes as the missing cube.
let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap();
assert_eq!(cyclic.quads.len(), 6);
assert!(
cyclic
.quads
.iter()
.all(|q| q.material.sprite == MISSING_SPRITE)
);
}
#[test]
fn replaced_layer_bakes_with_its_own_sprite() {
let library = library();
let child = BTreeMap::from([(
"layer0".to_owned(),
TextureSlot::Material(Material {
sprite: "horizon_cit:item/cit".to_owned(),
force_translucent: false,
}),
)]);
let model = library.resolve_with_textures("item/stick", &child).unwrap();
let baked = bake_resolved(&model, &Sprites).unwrap();
// The CIT sprite's own mask, one opaque pixel: front, back and 4 edges.
assert_eq!(baked.quads.len(), 6);
assert!(
baked
.quads
.iter()
.all(|q| q.material.sprite == "horizon_cit:item/cit")
);
assert_eq!(baked.particle.sprite, "horizon_cit:item/cit");
assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]);
}
}
+236
View File
@@ -102,10 +102,54 @@ impl ModelLibrary {
missing,
})
}
/// 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.
pub fn overlaid(&self, extra: BTreeMap<String, CuboidModel>) -> Result<Self> {
let mut models = self.models.clone();
let mut added = BTreeSet::new();
for (name, model) in extra {
let name = identifier(&name)?;
if !added.insert(name.clone()) {
return Err(error("duplicate normalized model identifier"));
}
// The built-ins win, as `from_models` keeps `missing` and
// `with_item_generator` keeps `generated`.
if name != MISSING_MODEL && name != GENERATED_MODEL {
models.insert(name, Arc::new(model));
}
}
Ok(Self {
models,
missing: Arc::clone(&self.missing),
})
}
/// 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_chain(name, None)
}
/// `name`'s resolved model with a virtual child in front of it that has
/// only `textures`: the child's slots win, and `#references` anywhere in
/// the chain re-bind through them (so `builtin/generated`'s
/// `particle: #layer0` follows a replaced `layer0`). The child's slots are
/// taken as given: sprites namespaced, references without their `#`, as
/// the JSON decoder leaves them. A cyclic chain is an
/// error, as for `resolve`: a caller wanting the missing model falls
/// back to it.
pub fn resolve_with_textures(
&self,
name: &str,
child: &BTreeMap<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
}
fn resolve_chain(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
let mut name = identifier(name)?;
let mut visited = BTreeSet::new();
let mut chain = Vec::new();
@@ -156,6 +200,9 @@ impl ModelLibrary {
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 mut textures = BTreeMap::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::new();
for start in slots.keys() {
@@ -274,6 +321,195 @@ mod tests {
}
);
}
fn library(models: &[(&str, &str)]) -> ModelLibrary {
ModelLibrary::with_item_generator(
models
.iter()
.map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap()))
.collect(),
)
.unwrap()
}
const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#;
const CIT: &str = "horizon_cit:minecraft/optifine/cit/x";
#[test]
fn child_textures_replace_slots_and_rebind_the_generated_particle() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
]);
let child = BTreeMap::from([(
"layer0".to_owned(),
TextureSlot::Material(Material {
sprite: CIT.to_owned(),
force_translucent: false,
}),
)]);
let model = library.resolve_with_textures("item/stick", &child).unwrap();
assert!(model.uses_item_generator());
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
assert_eq!(
model.display.contexts[6],
DisplayTransform::Explicit(Some(ItemTransform {
rotation: [0.0; 3],
translation: [0.0, 0.125, 0.0],
scale: [0.5; 3],
}))
);
// The library itself is untouched.
let plain = library.resolve("item/stick").unwrap();
assert_eq!(
plain.material("particle").unwrap().unwrap().sprite,
"minecraft:item/stick"
);
}
#[test]
fn overlaid_models_inherit_from_the_library_and_share_the_rest() {
let base = library(&[
("item/generated", GENERATED),
(
"item/handheld",
r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#,
),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
(
"item/apple",
r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#,
),
]);
let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#);
let over = base
.overlaid(BTreeMap::from([
(CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()),
(
"item/stick".to_owned(),
CuboidModel::from_json(
r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#,
)
.unwrap(),
),
]))
.unwrap();
let model = over.resolve(CIT).unwrap();
assert!(model.uses_item_generator());
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else {
panic!("handheld's right-hand transform is inherited");
};
assert_eq!(right.rotation, [0.0, -90.0, 55.0]);
let sprite = |l: &ModelLibrary| {
l.resolve("item/stick").unwrap().textures["layer0"]
.sprite
.clone()
};
assert_eq!(sprite(&over), "minecraft:item/bone");
assert_eq!(sprite(&base), "minecraft:item/stick");
assert!(!base.resolve(CIT).unwrap().uses_item_generator());
assert!(Arc::ptr_eq(
&base.models["minecraft:item/apple"],
&over.models["minecraft:item/apple"]
));
}
fn material(sprite: &str) -> TextureSlot {
TextureSlot::Material(Material {
sprite: sprite.to_owned(),
force_translucent: false,
})
}
#[test]
fn child_slots_take_part_in_reference_chains() {
let library = library(&[
(
"item/two",
r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##,
),
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
]);
let child = BTreeMap::from([
// A base alias binds through a slot only the child sets...
("tex".to_owned(), material(CIT)),
// ...and a child alias through the base's slots.
(
"particle".to_owned(),
TextureSlot::Reference("layer1".to_owned()),
),
// A child alias to itself resolves to nothing, without looping.
("self".to_owned(), TextureSlot::Reference("self".to_owned())),
]);
let model = library.resolve_with_textures("item/two", &child).unwrap();
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
assert_eq!(
model.material("particle").unwrap().unwrap().sprite,
"minecraft:item/b"
);
assert_eq!(model.material("self").unwrap(), None);
assert!(model.unresolved_textures.contains("self"));
// An absent base is the missing cube under the child; a cyclic one an error.
let absent = library
.resolve_with_textures("item/absent", &child)
.unwrap();
assert_eq!(absent.elements().len(), 1);
assert!(
library
.resolve_with_textures("horizon:loop", &child)
.is_err()
);
}
#[test]
fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() {
let base = library(&[
("item/generated", GENERATED),
("item/handheld", r#"{"parent":"item/generated"}"#),
(
"item/sword",
r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#,
),
]);
let model = |json: &str| CuboidModel::from_json(json).unwrap();
let over = base
.overlaid(BTreeMap::from([
(
"item/handheld".to_owned(),
model(
r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#,
),
),
// Neither built-in can be replaced.
("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)),
("builtin/generated".to_owned(), model("{}")),
]))
.unwrap();
// The base's sword sees the overlay's parent; the base library does not.
let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone();
let DisplayTransform::Explicit(Some(rotated)) = head(&over) else {
panic!("the replaced parent's head transform");
};
assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]);
assert_ne!(head(&base), head(&over));
// The generator kept: its `particle: #layer0`, which `{}` would drop.
let sword = over.resolve("item/sword").unwrap();
assert_eq!(
sword.material("particle").unwrap().unwrap().sprite,
"minecraft:item/sword"
);
// The missing cube kept, looked up by its own name, not as a fallback.
let missing = over.resolve("builtin/missing").unwrap();
assert_eq!(missing.elements().len(), 1);
// Two spellings of one id.
let twins = BTreeMap::from([
("item/x".to_owned(), model("{}")),
("minecraft:item/x".to_owned(), model("{}")),
]);
assert!(base.overlaid(twins).is_err());
}
#[test]
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
+2 -4
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
@@ -213,6 +213,48 @@ impl Registries {
}
}
}
/// Only what [`Self::fill_from_export`] takes from an export, to keep
/// in place of the export itself (a third of its memory).
pub fn from_export(r: &serde_json::Value) -> Self {
let mut out = Self::default();
out.fill_from_export(r);
out
}
/// [`Self::fill_from_export`] from an export already read with
/// [`Self::from_export`]: each field the game left empty, each registry
/// it did not list.
pub fn fill_from_exported(&mut self, e: &Self) {
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
if field.is_empty() {
field.clone_from(from);
}
};
fill(&mut self.effects, &e.effects);
fill(&mut self.enchantments, &e.enchantments);
fill(&mut self.attributes, &e.attributes);
fill(&mut self.damage_types, &e.damage_types);
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
}
}
if self.block_entities.is_empty() {
self.block_entities.clone_from(&e.block_entities);
}
if self.block_tag_members.is_empty() {
self.block_tag_members.clone_from(&e.block_tag_members);
}
if self.clock_time_markers.is_empty() {
self.clock_time_markers.clone_from(&e.clock_time_markers);
}
if self.advancement_criteria.is_empty() {
self.advancement_criteria
.clone_from(&e.advancement_criteria);
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -483,6 +483,21 @@ impl KeyBinds {
.find(|mapping| self.get(mapping) == name)
.and_then(|mapping| key_code(Self::default_of(mapping)))
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
/// hotbar slots, as the controls bind them now. Answers the key the menu
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
/// swaps instead of reaching the screen.
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
let name = key_name(key)?;
if self.get("key.swapOffhand") == name {
return Some(KeyCode::KeyF);
}
MAPPINGS
.iter()
.filter(|m| m.0.starts_with("key.hotbar."))
.find(|m| self.get(m.0) == name)
.and_then(|m| key_code(Self::default_of(m.0)))
}
}
#[cfg(test)]
@@ -519,6 +534,29 @@ mod tests {
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
}
#[test]
fn container_hotkeys_follow_the_bound_keys() {
let mut keys = KeyBinds::default();
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyF),
Some(KeyCode::KeyF)
);
assert_eq!(
keys.container_hotbar_key(KeyCode::Digit3),
Some(KeyCode::Digit3)
);
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
// The report's key: bound to the off hand, the menu takes it first.
keys.set("key.swapOffhand", "key.keyboard.tab");
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
keys.set("key.hotbar.3", "key.keyboard.t");
assert_eq!(
keys.container_hotbar_key(KeyCode::KeyT),
Some(KeyCode::Digit3)
);
}
#[test]
fn rebinding_translates_to_defaults() {
let mut k = KeyBinds::default();
@@ -207,6 +207,20 @@ impl ToggleKeys {
#[cfg(test)]
mod tests {
/// `KeyMapping.resetToggleKeys` (a new `LocalPlayer`): a sneak and a sprint toggled on in one
/// world are off in the next.
#[test]
fn resetting_the_toggle_keys_puts_every_mapping_up() {
let mut keys = ToggleKeys::default();
keys.key(0, KeyAction::Press, false, true);
keys.key(1, KeyAction::Press, false, true);
assert!(keys.is_down(0) && keys.is_down(1));
for key in &mut keys.0 {
key.reset();
}
assert!(!keys.is_down(0) && !keys.is_down(1));
}
use super::*;
#[test]
fn toggle_flips_on_press_and_ignores_release() {
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
+1 -1
View File
@@ -73,4 +73,4 @@ workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
@@ -0,0 +1,436 @@
//! How long a big resource pack takes to load and reload, on generated files only (no game
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
//! is printed as a Markdown table.
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
sprites::Sprites,
};
use std::{
fs,
path::{Path, PathBuf},
time::Instant,
};
/// A small deterministic generator: the same files on every run and every branch.
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
/// about as well as real textures do.
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
let mut colour = [0u8; 4];
for i in 0..side * side * frames {
if i % 3 == 0 {
let n = noise.next();
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
if alpha && n >> 40 & 3 == 0 {
colour[3] = 0;
}
}
pixels.extend_from_slice(&colour);
}
writer.write_image_data(&pixels).unwrap();
}
out
}
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
let n = noise.next() % 4;
format!(
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
h = 4 + n * 4
)
.into_bytes()
}
fn write(root: &Path, name: &str, bytes: &[u8]) {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
/// entries.
#[derive(Default)]
struct ZipOut {
out: Vec<u8>,
central: Vec<u8>,
count: u16,
}
impl ZipOut {
fn put(&mut self, name: &str, body: &[u8]) {
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
let offset = u32::try_from(self.out.len()).unwrap();
let sizes = [data.len() as u32, body.len() as u32];
self.out.extend(0x0403_4b50u32.to_le_bytes());
self.out.extend([20, 0, 0, 0, 8, 0]);
self.out.extend([0; 8]);
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
self.out.extend((name.len() as u16).to_le_bytes());
self.out.extend([0; 2]);
self.out.extend(name.as_bytes());
self.out.extend(&data);
self.central.extend(0x0201_4b50u32.to_le_bytes());
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
self.central.extend([0; 8]);
sizes
.iter()
.for_each(|s| self.central.extend(s.to_le_bytes()));
self.central.extend((name.len() as u16).to_le_bytes());
self.central.extend([0; 12]);
self.central.extend(offset.to_le_bytes());
self.central.extend(name.as_bytes());
self.count = self.count.checked_add(1).expect("too many zip entries");
}
fn finish(mut self, path: &Path) {
let start = self.out.len() as u32;
self.out.extend(&self.central);
self.out.extend(0x0605_4b50u32.to_le_bytes());
self.out.extend([0; 4]);
self.out.extend(self.count.to_le_bytes());
self.out.extend(self.count.to_le_bytes());
self.out.extend((self.central.len() as u32).to_le_bytes());
self.out.extend(start.to_le_bytes());
self.out.extend([0; 2]);
fs::write(path, self.out).unwrap();
}
}
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
/// files (lang, sounds, fonts, atlases) every reload merges.
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
let mut blocks = Vec::new();
for i in 0..1100 * scale {
let name = format!("block/b{i}");
let frames = if i % 50 == 0 { 8 } else { 1 };
write(
root,
&format!("assets/minecraft/textures/{name}.png"),
&png(noise, 16, frames, i % 7 == 0),
);
if frames > 1 {
write(
root,
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
br#"{"animation":{"frametime":2}}"#,
);
}
write(
root,
&format!("assets/minecraft/models/block/b{i}.json"),
&model(noise, "minecraft", &name),
);
blocks.push(format!("minecraft:{name}"));
}
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
for i in 0..count * scale {
let sub = if folder == "gui" { "sprites/" } else { "" };
write(
root,
&format!(
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
i % 20
),
&png(noise, side, 1, true),
);
}
}
for i in 0..1500 * scale {
write(
root,
&format!("assets/minecraft/models/item/i{i}.json"),
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
);
}
let lang: String = (0..4000)
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
.collect::<Vec<_>>()
.join(",");
write(
root,
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
write(
root,
"assets/minecraft/sounds.json",
br#"{"a":{"sounds":["a"]}}"#,
);
write(
root,
"assets/minecraft/atlases/blocks.json",
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
);
write(root, "pack.mcmeta", META);
blocks
}
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
/// with its own items' textures and models, its sounds and its lang files.
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for n in 0..190 {
let ns = format!("plugin{n}");
for i in 0..50 * scale {
let texture = format!("item/{}/t{i}", i % 5);
zip.put(
&format!("assets/{ns}/textures/{texture}.png"),
&png(noise, 32, 1, true),
);
zip.put(
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
&model(noise, &ns, &texture),
);
if i % 10 == 0 {
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
}
}
zip.put(
&format!("assets/{ns}/lang/en_us.json"),
br#"{"item.custom":"Custom"}"#,
);
}
let lang: String = (0..2000)
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
.collect::<Vec<_>>()
.join(",");
zip.put(
"assets/minecraft/lang/en_us.json",
format!("{{{lang}}}").as_bytes(),
);
zip.finish(path);
}
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
/// tiles beside them: what the big OptiFine packs look like on disk.
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
let mut zip = ZipOut::default();
zip.put("pack.mcmeta", META);
for i in 0..1100 * scale {
zip.put(
&format!("assets/minecraft/textures/block/b{i}.png"),
&png(noise, 64, 1, i % 7 == 0),
);
if i % 4 == 0 {
for tile in 0..12 {
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
&png(noise, 64, 1, false),
);
}
zip.put(
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
);
}
}
for i in 0..750 * scale {
zip.put(
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
&png(noise, 64, 1, true),
);
}
zip.finish(path);
}
fn median(mut ms: Vec<f64>) -> f64 {
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
median(
(0..runs)
.map(|_| {
before();
let started = Instant::now();
f();
started.elapsed().as_secs_f64() * 1000.0
})
.collect(),
)
}
fn zip_pack(id: &str, path: PathBuf) -> Pack {
// Read the way `packs::folder` reads a `resourcepacks/` zip.
let dir = path.parent().unwrap().join(format!("{id}-dir"));
fs::create_dir_all(&dir).unwrap();
let at = dir.join(path.file_name().unwrap());
fs::rename(&path, &at).unwrap();
packs::folder(&dir, packs::RESOURCE_FORMAT)
.into_iter()
.next()
.unwrap_or_else(|| panic!("{id} is not a pack"))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let scale = args
.iter()
.position(|a| a == "--scale")
.and_then(|i| args.get(i + 1))
.map_or(1, |s| s.parse().unwrap());
let runs = 3;
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
let vanilla_root = scratch.join("vanilla");
let blocks = vanilla(&vanilla_root, scale, &mut noise);
server_zip(&scratch.join("server.zip"), scale, &mut noise);
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
let server = zip_pack("server", scratch.join("server.zip"));
let hd = zip_pack("hd", scratch.join("hd.zip"));
let cache = scratch.join("cache");
let both = [hd.clone(), server.clone()];
let mut rows: Vec<(String, f64)> = Vec::new();
let mut row = |name: &str, ms: f64| {
eprintln!("{name}: {ms:.1} ms");
rows.push((name.to_owned(), ms));
};
let fresh = || {
let _ = fs::remove_dir_all(&cache);
};
row(
"overlay: build, both packs (cold)",
time(runs, fresh, || {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
}),
);
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
row(
"overlay: reuse, both packs (cache hit)",
time(
runs,
|| {},
|| {
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
},
),
);
// The pack screen: one pack turned off, then on again.
row(
"overlay: toggle the server pack off (new selection)",
time(
runs,
|| {
let only =
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
fs::remove_dir_all(&only).unwrap();
},
|| {
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
},
),
);
row(
"models: parse every model",
time(
runs,
|| {},
|| {
std::hint::black_box(ParsedModels::load(&root));
},
),
);
let sprite_cache = root.join("horizon-sprites.cache");
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
let tiles: Vec<String> = (0..1100 * scale)
.step_by(4)
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
.collect();
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
time(
runs,
|| {
if cold {
let _ = fs::remove_file(&sprite_cache);
}
},
|| {
let mut sprites = Sprites::with_disk_cache(&root);
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str));
}
for name in names {
if files {
sprites.insert_file(&root, name).unwrap();
} else {
sprites.insert(&root, name).unwrap();
}
}
sprites.save_disk_cache();
sprites.finish();
std::hint::black_box(&sprites.pixels);
},
)
};
row(
"block sprites: one by one (no cache)",
sprites(true, false, &blocks, false),
);
row(
"block sprites: prefetched (no cache)",
sprites(true, true, &blocks, false),
);
row(
"block sprites: from the disk cache",
sprites(false, false, &blocks, false),
);
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
row(
"GUI atlas: decode and stitch, both packs",
time(
runs,
|| {},
|| {
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
eprintln!("GUI atlas failed: {e}");
}
},
),
);
row(
"GUI atlas: vanilla alone",
time(
runs,
|| {},
|| {
let _ = HudAtlas::build(&vanilla_root);
},
),
);
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
println!("|---|---:|");
for (name, ms) in &rows {
println!("| {name} | {ms:.0} |");
}
let _ = fs::remove_dir_all(&scratch);
}
@@ -93,6 +93,8 @@ pub struct RenderAssets {
pub model_gallery: Vec<Value>,
pub animations: Vec<crate::sprites::SpriteAnimation>,
pub sprite_layers: std::collections::BTreeMap<String, u32>,
/// OptiFine's emissive block overlays: base sprite and overlay sprite names.
pub emissive: Vec<(String, String)>,
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
@@ -242,6 +244,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let mut emissive = Vec::new();
let connected = if draws {
crate::native_emissive::load(root, &mut sprites);
// A side's sprite, for rules that connect by tile: the first quad facing it.
@@ -270,6 +273,11 @@ impl RenderAssets {
} else {
Default::default()
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
sprites.finish();
@@ -289,6 +297,7 @@ impl RenderAssets {
model_gallery: models.states,
animations: sprites.animations,
sprite_layers: sprites.layers,
emissive,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
@@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec<String> {
out.sort();
out
}
/// The file of a texture identifier.
/// The file of a texture identifier. A Custom Item Textures id (`cit_assets`) names its
/// resource path, wherever the folder: `directory` is not used for it. An id whose path would
/// leave the root (a pack's model may write any) names a file that is never there.
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
// No file can have a NUL in its name, so this one is never there.
const NOWHERE: &str = "assets/minecraft/textures/horizon/\0refused.png";
if texture.starts_with("horizon_cit:") {
return root.join(
crate::cit_assets::resource(texture, "png")
.as_deref()
.unwrap_or(NOWHERE),
);
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -76,6 +76,9 @@ pub struct BlockEntities {
/// The fluid tanks of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
/// for the renderer: the mesher lists block entities by their template,
/// which leaves out a mod block whose template has none. One taken out
@@ -192,6 +195,13 @@ impl BlockEntities {
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
self.mod_energy = energy;
}
/// The energy buffer of a mod block's block entity in block state `state`.
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -120,7 +120,12 @@ pub struct Paint {
pub tint: u32,
pub light: u32,
pub lights: (Vec3, Vec3),
/// The texture's OptiFine emissive overlay (`_e`): drawn again over it at full
/// brightness, with the same shading.
pub glow: Option<Rect>,
}
/// Full sky and block light, in [`vertex_light`]'s form.
const FULL_BRIGHT: u32 = 240 | (240 << 8);
/// `ModelPart.render`: translate by pose/16, rotate ZYX, scale; children inherit.
/// `adjust` may change a part's pose by name (`setupAnim`); `skip` hides parts.
pub fn draw_part(
@@ -142,6 +147,7 @@ pub fn draw_part(
tint,
light,
lights,
glow,
} = paint;
for poly in &part.polygons {
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
@@ -155,17 +161,19 @@ pub fn draw_part(
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
color,
light,
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
for (v, position) in poly.iter().zip(pos) {
out.push(Vertex {
normal: [0; 4],
pos: position.to_array(),
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
glint_uv: [0.0, 0.0],
});
}
}
}
for (child_name, child) in &part.children {
@@ -771,6 +779,8 @@ pub struct Scene<'a> {
pub max_y: i32,
pub world: &'a World,
pub sprites: &'a HashMap<String, Rect>,
/// The packs' OptiFine emissive overlays of the sprites.
pub emissive: &'a horizon_optifine::emissive::Emissive,
pub nether: bool,
/// Chest lid openness (`ChestLidController.getOpenNess`) at a position.
pub lid: &'a dyn Fn([i32; 3]) -> f32,
@@ -939,6 +949,7 @@ impl BlockEntityRenderer {
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
glow: None,
},
&mut vertices,
);
@@ -1279,6 +1290,9 @@ impl BlockEntityRenderer {
tint,
light: vertex_light(packed),
lights,
glow: (!crumbling)
.then(|| scene.emissive.overlay(name).and_then(sprite))
.flatten(),
})
};
let none = |_: &str, _: &mut [f32; 9]| {};
@@ -1374,15 +1388,19 @@ impl BlockEntityRenderer {
let vertices = &self.closed_chests[usize::from(scene.nether)]
[*part as usize][*y_rot as usize / 90];
let origin = base.w_axis.truncate();
out.extend(vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
uv: [
paint.sprite.u0 + (paint.sprite.u1 - paint.sprite.u0) * v.uv[0],
paint.sprite.v0 + (paint.sprite.v1 - paint.sprite.v0) * v.uv[1],
],
light: paint.light,
..*v
}));
let passes = std::iter::once((paint.sprite, paint.light))
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
for (r, light) in passes {
out.extend(vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
uv: [
r.u0 + (r.u1 - r.u0) * v.uv[0],
r.v0 + (r.v1 - r.v0) * v.uv[1],
],
light,
..*v
}));
}
}
return;
}
@@ -2106,6 +2124,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &base_sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2305,6 +2324,7 @@ mod tests {
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
@@ -2313,6 +2333,55 @@ mod tests {
assert_eq!(out[2].uv, [0.5, 0.5]);
}
/// An OptiFine emissive overlay draws each polygon again with its own sprite at full
/// brightness, shaded alike.
#[test]
fn a_glow_draws_the_part_again_at_full_brightness() {
let part = ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[
[0.0, 0.0, 0.0, 0.0, 0.0],
[16.0, 0.0, 0.0, 1.0, 0.0],
[16.0, 0.0, 16.0, 1.0, 1.0],
[0.0, 0.0, 16.0, 0.0, 1.0],
]],
children: vec![],
};
let rect = |u0, v0| Rect {
u0,
v0,
u1: u0 + 0.5,
v1: v0 + 0.5,
w: 1,
h: 1,
};
let mut out = Vec::new();
let light = vertex_light(0x52);
draw_part(
&part,
"root",
Mat4::IDENTITY,
&|_, _| {},
Paint {
sprite: rect(0.0, 0.0),
tint: 0xFFFFFF,
light,
lights: diffuse_lights(false),
glow: Some(rect(0.5, 0.5)),
},
&mut out,
);
assert_eq!(out.len(), 8);
let (base, glow) = out.split_at(4);
assert!(base.iter().all(|v| v.light == light));
assert!(glow.iter().all(|v| v.light == 240 | (240 << 8)));
assert_eq!(glow[2].uv, [1.0, 1.0]);
assert_eq!(base[2].uv, [0.5, 0.5]);
for (b, g) in base.iter().zip(glow) {
assert_eq!((b.pos, b.color), (g.pos, g.color));
}
}
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
/// and plain text stays on the sign's plane.
@@ -2341,6 +2410,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2445,6 +2515,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
@@ -2553,6 +2624,7 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -419,6 +419,31 @@ pub enum CameraFluid {
PowderSnow,
}
/// `Camera.NearPlane`: the near plane's centre and its half extents toward
/// the camera's left and up, relative to the camera.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NearPlane {
pub forward: [f64; 3],
pub left: [f64; 3],
pub up: [f64; 3],
}
impl NearPlane {
/// `forward`, `getTopLeft`, `getTopRight`, `getBottomLeft`, `getBottomRight`:
/// the points `Camera.getFluidInCamera` looks for lava and powder snow at.
pub fn points(&self) -> [[f64; 3]; 5] {
let corner = |up: f64, left: f64| -> [f64; 3] {
std::array::from_fn(|i| self.forward[i] + self.up[i] * up + self.left[i] * left)
};
[
self.forward,
corner(1.0, 1.0),
corner(1.0, -1.0),
corner(-1.0, 1.0),
corner(-1.0, -1.0),
]
}
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
@@ -481,6 +506,19 @@ impl Camera {
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.getNearPlane(fov)` for a `width` x `height` window, at the
/// projection's `zNear` of 0.05.
pub fn near_plane(&self, fov: f32, width: f32, height: f32) -> NearPlane {
let z_near = f64::from(0.05f32);
let aspect = f64::from(width) / f64::from(height);
let plane_height = horizon_core::jmath::tan(f64::from(fov * DEG_TO_RAD) / 2.0) * z_near;
let scale = |v: [f32; 3], by: f64| v.map(|c| f64::from(c) * by);
NearPlane {
forward: scale(self.forwards, z_near),
left: scale(self.left, plane_height * aspect),
up: scale(self.up, plane_height),
}
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
@@ -8,7 +8,13 @@ use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
@@ -94,18 +100,98 @@ pub enum BlockEntity {
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// A mod's block entity: its type id (`mymod:machine`), its saved
/// A mod's block entity, read and changed through its fields
/// (`ModBlockEntityFields`) as a struct's: `entity.nbt`. Each change goes
/// through `DerefMut` and gives it a new `revision`, so a client tells
/// whether what it kept of it (`BlockEntityDraw::keep`) is still right
/// without comparing the whole entity, and no write can skip that.
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
/// (`Items`) when its block has a container (`ModContainer`), and the data
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
/// which the mod sets and nothing saves.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModBlockEntity {
pub struct ModBlockEntityFields {
pub id: String,
pub nbt: Compound,
pub items: Vec<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// Which contents a `ModBlockEntity` has had: one per entity made (a clone
/// is another), counted on by each change. Equal revisions, equal contents.
/// Neither saved nor compared.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModRevision {
born: u64,
changes: u64,
}
static MOD_BLOCK_ENTITIES_MADE: AtomicU64 = AtomicU64::new(0);
impl ModBlockEntity {
pub fn new(fields: ModBlockEntityFields) -> Self {
Self {
fields,
revision: ModRevision {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
/// left alone, so a tag sent again unchanged keeps its revision.
pub fn merge_update_tag(&mut self, tag: &Compound) {
for (k, v) in tag {
if self.nbt.get(k) != Some(v) {
self.nbt.insert(k.clone(), v.clone());
}
}
}
}
impl From<ModBlockEntityFields> for ModBlockEntity {
fn from(fields: ModBlockEntityFields) -> Self {
Self::new(fields)
}
}
impl Default for ModBlockEntity {
fn default() -> Self {
Self::new(ModBlockEntityFields::default())
}
}
impl Clone for ModBlockEntity {
fn clone(&self) -> Self {
Self::new(self.fields.clone())
}
}
impl PartialEq for ModBlockEntity {
fn eq(&self, other: &Self) -> bool {
self.fields == other.fields
}
}
impl std::fmt::Debug for ModBlockEntity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fields.fmt(f)
}
}
impl std::ops::Deref for ModBlockEntity {
type Target = ModBlockEntityFields;
fn deref(&self) -> &ModBlockEntityFields {
&self.fields
}
}
impl std::ops::DerefMut for ModBlockEntity {
fn deref_mut(&mut self) -> &mut ModBlockEntityFields {
self.revision.changes += 1;
&mut self.fields
}
}
/// The container of a mod block's block entity, as a `WorldlyContainer`
/// says it: its slot count, the slots each face reaches
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
@@ -135,6 +221,23 @@ pub struct ModFluidTanks {
/// The tanks a drain may take from (`canTakeItemThroughFace`).
pub outputs: Vec<usize>,
}
/// The energy buffer of a mod block's block entity (NeoForge's
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
/// moves, and the faces a fill may come through and a drain may leave by.
/// The amount is not here: it lives in the entity's saved compound, under
/// `energy`, so it survives restarts and the block's own tick reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModEnergyBuffer {
pub capacity: i32,
/// The most one insert moves; 0 takes none.
pub max_insert: i32,
/// The most one extract moves; 0 gives none.
pub max_extract: i32,
/// The faces, by `Direction` number, a fill may come through.
pub insert_faces: [bool; 6],
/// The faces a drain may leave by.
pub extract_faces: [bool; 6],
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
@@ -588,3 +691,64 @@ pub struct CommandBlock {
pub auto: bool,
pub condition_met: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "mymod:machine".into(),
data: vec![0],
..Default::default()
});
let made = machine.revision();
// Reads leave it.
assert_eq!(machine.data[0] + machine.nbt.len() as i32, 0);
assert_eq!(machine.revision(), made);
// Every way to change a field moves it.
let mut seen = vec![made];
let changes: [fn(&mut ModBlockEntity); 4] = [
|m| m.data[0] = 1,
|m| _ = m.nbt.insert("progress".into(), Tag::Int(3)),
|m| m.items.push(None),
|m| m.id.push('s'),
];
for change in changes {
change(&mut machine);
assert!(!seen.contains(&machine.revision()));
seen.push(machine.revision());
}
// Another entity, or a clone, never shares one, even with equal
// contents; equality is the contents' alone.
let copy = machine.clone();
assert_eq!(copy, machine);
assert!(!seen.contains(&copy.revision()));
let fresh = ModBlockEntity::default();
assert_ne!(fresh.revision(), ModBlockEntity::default().revision());
}
#[test]
fn an_update_tag_sent_again_unchanged_keeps_the_revision() {
let mut machine = ModBlockEntity::default();
let tag = Compound::from([
("progress".into(), Tag::Int(3)),
("fuel".into(), Tag::Int(9)),
]);
machine.merge_update_tag(&tag);
let merged = machine.revision();
machine.merge_update_tag(&tag);
assert_eq!(machine.revision(), merged);
// One field changed: replaced, the other kept, the revision moved.
machine.merge_update_tag(&Compound::from([("progress".into(), Tag::Int(4))]));
assert_ne!(machine.revision(), merged);
assert_eq!(
machine.nbt,
Compound::from([
("progress".into(), Tag::Int(4)),
("fuel".into(), Tag::Int(9)),
])
);
}
}
@@ -0,0 +1,164 @@
//! The ids Custom Item Textures' files take in Horizon's atlas and model library. A CIT rule
//! names its textures and models by resource path, mostly under `optifine/cit/` where vanilla
//! never looks: those take the reserved namespace [`NAMESPACE`], their path the resource path's
//! (`assets/minecraft/optifine/cit/swords/flame.png` is
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
//! `models/`) keeps its vanilla id, so it is not packed twice.
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The namespace of CIT files outside `textures/` and `models/`.
pub const NAMESPACE: &str = "horizon_cit";
/// `<namespace>:<path>`, if both keep to the identifier rules (lower-case letters, digits,
/// `_.-`, and `/` in the path). CIT Resewn refuses the other paths too.
fn identifier(namespace: &str, path: &str) -> Option<String> {
if namespace.is_empty() || path.is_empty() {
return None;
}
horizon_client_core::identifier::parse_identifier(&format!("{namespace}:{path}"))
}
/// The id of a resource path ending in `.<ext>`: `assets/<ns>/<folder>/<p>` is `<ns>:<p>`, any
/// other `assets/<rest>` is `horizon_cit:<rest>`.
fn id(resource: &str, folder: &str, ext: &str) -> Option<String> {
let rest = resource
.strip_prefix("assets/")?
.strip_suffix(ext)?
.strip_suffix('.')?;
if let Some((namespace, path)) = rest.split_once('/')
&& let Some(path) = path.strip_prefix(folder).and_then(|p| p.strip_prefix('/'))
{
// A pack's own `assets/horizon_cit/` would name another file than its id maps back to.
return (namespace != NAMESPACE)
.then(|| identifier(namespace, path))
.flatten();
}
identifier(NAMESPACE, rest)
}
/// The atlas sprite id of a `.png` resource path.
pub fn sprite_id(resource: &str) -> Option<String> {
id(resource, "textures", "png")
}
/// The model id of a `.json` resource path.
pub fn model_id(resource: &str) -> Option<String> {
id(resource, "models", "json")
}
/// The resource path of a `horizon_cit` id, with the extension `ext`; `None` for other ids,
/// and for one that would leave the resource root (a pack model may write any id).
pub fn resource(id: &str, ext: &str) -> Option<String> {
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
/// a warning.
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.textures.iter().map(|(_, path)| path);
for path in rule.texture.iter().chain(named) {
match sprite_id(path) {
Some(id) => {
out.insert(id);
}
None => log::warn!(
"custom item textures: {} names {path}, which is not a valid texture id",
rule.path
),
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas_sources::texture_path;
use std::path::Path;
#[test]
fn cit_paths_take_the_reserved_namespace_and_come_back() {
let png = "assets/minecraft/optifine/cit/swords/flame.png";
let id = sprite_id(png).unwrap();
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
assert_eq!(resource(&id, "png").as_deref(), Some(png));
let json = "assets/minecraft/optifine/cit/swords/flame.json";
let id = model_id(json).unwrap();
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
assert_eq!(resource(&id, "json").as_deref(), Some(json));
}
#[test]
fn files_under_textures_and_models_keep_their_vanilla_ids() {
let id = sprite_id("assets/minecraft/textures/item/stick.png");
assert_eq!(id.as_deref(), Some("minecraft:item/stick"));
let id = model_id("assets/gems/models/item/ruby.json");
assert_eq!(id.as_deref(), Some("gems:item/ruby"));
// A model id is not a resource of this module's own.
assert_eq!(resource("minecraft:item/stick", "png"), None);
// `textures` as a folder name elsewhere is still a CIT path.
let id = sprite_id("assets/minecraft/optifine/cit/textures/a.png");
assert_eq!(
id.as_deref(),
Some("horizon_cit:minecraft/optifine/cit/textures/a")
);
}
#[test]
fn paths_no_identifier_can_name_are_refused() {
for path in [
"assets/minecraft/optifine/cit/Flame.png",
"assets/minecraft/optifine/cit/fire sword.png",
"assets/minecraft/textures/item/Stick.png",
"assets/minecraft/optifine/cit/a.json",
"optifine/cit/a.png",
] {
assert_eq!(sprite_id(path), None, "{path}");
}
assert_eq!(model_id("assets/minecraft/optifine/cit/A.json"), None);
assert_eq!(
sprite_id("assets//textures/item/x.png"),
None,
"no namespace"
);
}
#[test]
fn the_reserved_namespace_names_only_files_inside_the_root() {
// A pack's own `horizon_cit` namespace would map back to another file.
assert_eq!(sprite_id("assets/horizon_cit/textures/item/x.png"), None);
// A model may write any id: only ones inside the root name a file.
for id in [
"horizon_cit:../../etc/x",
"horizon_cit:a/../../x",
"horizon_cit:a\\..\\x",
] {
assert_eq!(resource(id, "png"), None, "{id}");
// Nor does the texture's file: refused, not read from elsewhere.
let file = texture_path(Path::new("/r"), id, "textures");
assert!(file.starts_with("/r/assets") && !file.to_string_lossy().contains(".."));
}
let escape = texture_path(Path::new("/r"), "minecraft:../../../x", "textures");
assert!(!escape.to_string_lossy().contains(".."), "{escape:?}");
}
#[test]
fn both_id_forms_find_their_file() {
let root = Path::new("/r");
assert_eq!(
texture_path(root, "horizon_cit:minecraft/optifine/cit/a", "textures"),
root.join("assets/minecraft/optifine/cit/a.png")
);
assert_eq!(
texture_path(root, "minecraft:item/stick", "textures"),
root.join("assets/minecraft/textures/item/stick.png")
);
}
}
+391 -17
View File
@@ -41,9 +41,35 @@ pub enum Pick {
Quarters([TileLayer; 4]),
}
/// A tile drawn over a face, after its own (`overlay*` methods).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Overlay {
pub layer: u16,
/// `layer=translucent`: drawn in the translucent pass, else with an alpha cutout.
pub translucent: bool,
/// `tintIndex` and the state whose tint it is (`tintBlock`, else the face's block).
pub tint: Option<(i32, Option<u32>)>,
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
overlay: OverlayJoin,
}
/// What an overlay rule joins: `connectBlocks` as state ids and `connectTiles` as sprite
/// layers (sorted), and its tint.
#[derive(Default)]
struct OverlayJoin {
states: Vec<u32>,
tiles: Vec<u16>,
tint: Option<(i32, Option<u32>)>,
}
impl OverlayJoin {
fn any(&self) -> bool {
!self.states.is_empty() || !self.tiles.is_empty()
}
}
#[derive(Clone, Copy, Default)]
@@ -53,8 +79,15 @@ struct StateInfo {
axis: Option<Axis>,
/// `half=upper`: `linked` random tiles follow the lower half.
upper: bool,
/// A pane's arms (`north`, `east`, `south`, `west` true), as bits of [`ARMS`].
arms: u8,
/// Rules name the state or one of its sides' sprites ([`ConnectedTextures::stacked_pane_hides`]).
has_rules: bool,
}
/// A pane's arm properties, in [`StateInfo::arms`]' bit order.
const ARMS: [&str; 4] = ["north", "east", "south", "west"];
#[derive(Default)]
pub struct ConnectedTextures {
rules: Vec<LoadedRule>,
@@ -98,14 +131,24 @@ impl ConnectedTextures {
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
// meshes see no block entity).
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
// Rules with a custom name load no tiles: section meshes see no block entity.
rules.retain(|r| r.name.is_none());
// Rule indices are `u16`.
rules.truncate(usize::from(u16::MAX));
if rules.is_empty() {
return Self::default();
}
// The tiles decode side by side; the loop below inserts them in rule order, under the
// same names.
let tiles: Vec<String> = rules
.iter()
.flat_map(|rule| &rule.tiles)
.filter_map(|tile| match tile {
Tile::Texture(path) => Some(sprite_name(path)),
_ => None,
})
.collect();
sprites.prefetch(root, tiles.iter().map(String::as_str));
let loaded = rules
.into_iter()
.filter_map(|rule| {
@@ -130,7 +173,11 @@ impl ConnectedTextures {
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
overlay: OverlayJoin::default(),
}),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
@@ -144,7 +191,11 @@ impl ConnectedTextures {
.map(|s| (s.name.as_str(), &s.properties))
});
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
if out
.rules
.iter()
.any(|r| r.rule.connect == Connect::Tile || !r.overlay.tiles.is_empty())
{
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
@@ -152,6 +203,23 @@ impl ConnectedTextures {
})
.collect();
}
// Panes whose sprites tile rules name (`matchTiles=glass`) are connected too, by a rule
// that can draw on them: here, one naming no block (a rule naming the pane is in its
// own list already).
for id in (0..out.states.len()).filter(|_| !out.by_layer.is_empty()) {
let pane = registry
.state(BlockStateId(id as u32))
.is_some_and(|s| s.name.ends_with("glass_pane"));
if pane && !out.states[id].has_rules {
out.states[id].has_rules = Face::ALL.into_iter().any(|f| {
face_layer(BlockStateId(id as u32), f).is_some_and(|l| {
out.list(&out.by_layer, l as usize)
.iter()
.any(|&i| out.rules[usize::from(i)].rule.match_blocks.is_empty())
})
});
}
}
out
}
@@ -166,6 +234,48 @@ impl ConnectedTextures {
rules,
..Self::default()
};
let states: Vec<_> = states.collect();
// Each block's states, for `connectBlocks` and `tintBlock`.
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
if out.rules.iter().any(|r| r.rule.method.is_overlay()) {
for (id, state) in states.iter().enumerate() {
if let Some((name, _)) = state {
block_states.entry(name).or_default().push(id as u32);
}
}
}
for r in out.rules.iter_mut().filter(|r| r.rule.method.is_overlay()) {
let rule = &r.rule;
// `connectBlocks` and `connectTiles` are "only for method overlay".
if rule.method == Method::Overlay {
for m in &rule.connect_blocks {
let candidates = block_states
.get(m.id.as_str())
.map_or(&[][..], Vec::as_slice);
r.overlay.states.extend(candidates.iter().filter(|&&id| {
states[id as usize].is_some_and(|(name, props)| {
m.matches(name, |k| props.get(k).map(String::as_str))
})
}));
}
r.overlay.states.sort_unstable();
r.overlay.states.dedup();
r.overlay.tiles = rule
.connect_tiles
.iter()
.filter_map(|t| layer(&sprite_name(t)).map(|l| l as u16))
.collect();
r.overlay.tiles.sort_unstable();
}
// A `tintBlock` the game doesn't have tints nothing.
let tint = match rule.tint_block.as_deref() {
None => Some(None),
Some(block) => block_states.get(block).map(|ids| Some(ids[0])),
};
r.overlay.tint = tint
.filter(|_| rule.tint_index >= 0)
.map(|b| (rule.tint_index, b));
}
let mut blocks = HashMap::new();
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
@@ -213,6 +323,13 @@ impl ConnectedTextures {
_ => None,
},
upper: prop("half") == Some("upper"),
arms: ARMS
.iter()
.enumerate()
.filter(|&(_, arm)| prop(arm) == Some("true"))
.fold(0, |bits, (i, _)| bits | 1 << i),
// Set below, with the state's rule list.
has_rules: false,
});
list.clear();
if let Some(candidates) = by_block.get(name) {
@@ -222,6 +339,7 @@ impl ConnectedTextures {
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
);
}
out.states.last_mut().expect("pushed above").has_rules = !list.is_empty();
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
@@ -232,6 +350,35 @@ impl ConnectedTextures {
self.rules.is_empty()
}
/// Whether a pane's top or bottom strip, centred at `centre` (block-local x, z), is hidden by
/// the pane `other` stacked on it (Continuity's "Glass Pane Culling Fix"): only for a pane
/// with connected-texture rules, under or over the same block, where `other` has the same
/// part (the post, or the arm toward the strip).
pub fn stacked_pane_hides(&self, state: u32, other: u32, [x, z]: [f32; 2]) -> bool {
let (Some(own), Some(theirs)) = (
self.states.get(state as usize),
self.states.get(other as usize),
) else {
return false;
};
if own.block != theirs.block || !own.has_rules {
return false;
}
let (dx, dz) = (x - 0.5, z - 0.5);
// The post is 2 pixels wide; an arm runs from it to the block's edge.
if dx.abs() <= 1.0 / 16.0 && dz.abs() <= 1.0 / 16.0 {
return true;
}
let arm = if dz.abs() > dx.abs() {
if dz < 0.0 { 0 } else { 2 }
} else if dx > 0.0 {
1
} else {
3
};
theirs.arms & 1 << arm != 0
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
@@ -249,6 +396,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
@@ -279,14 +427,16 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
&& let Some(pick) =
self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay)
{
return pick;
}
}
}
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
/// One rule on a face: `None` when it doesn't apply, picks `<skip>` or draws an overlay
/// (handed to `overlay`: the rules after it still choose the face's own sprite).
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
@@ -297,6 +447,7 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
overlay: &mut impl FnMut(Overlay),
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
@@ -312,8 +463,17 @@ impl ConnectedTextures {
return None;
}
let info = self.states.get(state as usize).copied().unwrap_or_default();
let join = &r.overlay;
let joins = |offset: [i32; 3]| {
let other = neighbour(offset);
if join.any() {
// An overlay joins the blocks it is drawn beside.
return join.states.binary_search(&other).is_ok()
|| self
.face_layers
.get(other as usize)
.is_some_and(|f| join.tiles.binary_search(&f[face as usize]).is_ok());
}
match rule.connect {
Connect::Block => {
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
@@ -332,7 +492,36 @@ impl ConnectedTextures {
};
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
let index = match rule.method {
let mut draw = |t: TileLayer| {
if let TileLayer::Layer { layer, .. } = t {
overlay(Overlay {
layer,
translucent: rule.layer == ctm::Layer::Translucent,
tint: join.tint,
});
}
};
// An overlay picks its tile as the method it is the overlay of.
let method = match rule.method {
Method::Overlay => {
// The 17 transition tiles, toward the blocks it names (none: it draws nothing).
if join.any() {
let mask = geometry::neighbours(axes(face), face.normal(), false, joins);
geometry::overlay_tiles(mask).for_each(|i| draw(tile(i)));
}
return None;
}
Method::OverlayCtm => Method::Ctm,
Method::OverlayRandom => Method::Random,
Method::OverlayRepeat => Method::Repeat,
Method::OverlayFixed => Method::Fixed,
Method::OverlayHorizontal => Method::Horizontal,
Method::OverlayVertical => Method::Vertical,
Method::OverlayHorizontalVertical => Method::HorizontalVertical,
Method::OverlayVerticalHorizontal => Method::VerticalHorizontal,
m => m,
};
let index = match method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
@@ -377,9 +566,13 @@ impl ConnectedTextures {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Overlays are not loaded yet.
// Every overlay was mapped to its method above.
_ => return None,
};
if rule.method.is_overlay() {
draw(tile(index));
return None;
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
@@ -412,6 +605,7 @@ mod tests {
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
tiles: rule
.tiles
.iter()
@@ -432,7 +626,7 @@ mod tests {
}
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
/// upper); the stone sprite is layer 7.
/// upper); the stone sprite is layer 7, glass 3.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
@@ -452,7 +646,11 @@ mod tests {
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| (name == "minecraft:block/stone").then_some(7),
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
_ => None,
},
)
}
@@ -473,7 +671,7 @@ mod tests {
blocks: &[([i32; 3], u32)],
) -> Pick {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {})
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -596,8 +794,8 @@ mod tests {
)]);
let mut tiles = std::collections::HashSet::new();
for x in 0..20 {
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {});
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {});
assert_eq!(lower, upper, "{x}");
tiles.insert(format!("{lower:?}"));
}
@@ -610,8 +808,9 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
)]);
let at =
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
let at = |y, biome: &'static str| {
ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {})
};
assert_eq!(at(10, "minecraft:desert"), tile(1000));
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
@@ -645,7 +844,7 @@ mod tests {
"glass.properties",
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
)]);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {});
assert_eq!(at([0, 64, 0]), tile(1000));
// The north face's right is west; rows run down.
assert_eq!(at([-1, 64, 0]), tile(1001));
@@ -710,6 +909,181 @@ mod tests {
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
}
/// The overlays `pick` hands out, with what it picks for the face itself.
fn overlays_in(
ctm: &ConnectedTextures,
state: u32,
face: Face,
blocks: &[([i32; 3], u32)],
) -> (Vec<u16>, Pick) {
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
let mut out = Vec::new();
let pick = ctm.pick(
state,
3,
face,
[0, 64, 0],
at,
|| None,
|o| out.push(o.layer),
);
(out, pick)
}
#[test]
fn overlays_draw_toward_the_blocks_they_name() {
// Stone gets glass's edge where glass is beside it; then a fixed tile for the stone.
let ctm = world(&[
(
"a_overlay.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\ntintIndex=0\ntintBlock=oak_log",
),
("b.properties", "matchBlocks=stone\nmethod=fixed\ntiles=0"),
]);
// Nothing beside: no overlay, and b draws.
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[]), (vec![], tile(2000)));
// Glass to the north of a top face (its texture's up): tile 15. Glass east and west
// too: one tile for the three sides, 13.
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 1)]).0, [1015]);
let three = [([0, 0, -1], 1), ([1, 0, 0], 1), ([-1, 0, 0], 1)];
assert_eq!(overlays_in(&ctm, 2, Face::Up, &three).0, [1013]);
// Glass only at the north-east corner: the corner tile, 14.
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([1, 0, -1], 1)]).0, [1014]);
// Stone beside stone draws nothing.
assert_eq!(
overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 2)]).0,
[0u16; 0]
);
let mut tint = None;
ctm.pick(
2,
3,
Face::Up,
[0, 64, 0],
|o| if o == [0, 0, -1] { 1 } else { 0 },
|| None,
|o| {
tint = Some((o.tint, o.translucent));
},
);
assert_eq!(
tint,
Some((Some((0, Some(3))), false)),
"oak_log's first state is 3"
);
}
#[test]
fn overlay_joins_layers_and_tints() {
let mut ctm = world(&[
(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectTiles=glass\ntiles=0-16\nlayer=translucent\ntintIndex=0\ntintBlock=no_such_block",
),
(
"b.properties",
"matchBlocks=glass\nmethod=overlay_ctm\nconnectBlocks=stone\ntiles=0-46",
),
]);
// connectTiles joins neighbours showing a named sprite on the same side: the door to the
// north shows glass (sprite 3) on top.
let mut faces = vec![[9u16; 6]; 7];
faces[5][Face::Up as usize] = 3;
ctm.face_layers = faces;
let mut got = Vec::new();
let door_north = |o: [i32; 3]| if o == [0, 0, -1] { 5 } else { 0 };
ctm.pick(
2,
7,
Face::Up,
[0, 64, 0],
door_north,
|| None,
|o| got.push(o),
);
assert_eq!(
got,
[Overlay {
layer: 1015,
translucent: true,
// A tintBlock the game lacks tints nothing.
tint: None,
}]
);
// connectBlocks is only for `overlay`: overlay_ctm joins glass by `connect`, not stone.
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 2)]);
assert_eq!(layers, [2000]);
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
assert_eq!(layers, [2001]);
}
#[test]
fn overlay_methods_pick_like_their_own() {
let ctm = world(&[
(
"a.properties",
"matchBlocks=glass\nmethod=overlay_ctm\ntiles=0-46\nlayer=translucent",
),
(
"b.properties",
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=<skip>",
),
(
"c.properties",
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=0",
),
]);
// overlay_ctm joins glass (connect=block): the left end of a row, tile 1; b's <skip>
// draws nothing; c draws; the face keeps its own sprite.
let (layers, pick) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
}
#[test]
fn stacked_panes_hide_the_strips_they_share() {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
// 0 a pane with north and east arms, 1 the same pane with only a north arm, 2 another
// pane block, 3 a pane no rule names.
let states = [
(
"minecraft:glass_pane",
props(&[("north", "true"), ("east", "true")]),
),
("minecraft:glass_pane", props(&[("north", "true")])),
(
"minecraft:white_stained_glass_pane",
props(&[("north", "true")]),
),
("minecraft:iron_bars", props(&[("north", "true")])),
];
let ctm = ConnectedTextures::build(
rules(&[(
"pane.properties",
"matchBlocks=glass_pane white_stained_glass_pane\nmethod=ctm\ntiles=0-46",
)]),
states.iter().map(|(n, p)| Some((*n, p))),
|_| None,
);
let (post, north, east) = ([0.5, 0.5], [0.5, 0.2], [0.8, 0.5]);
assert!(ctm.stacked_pane_hides(0, 1, post));
assert!(ctm.stacked_pane_hides(0, 1, north));
// The pane above has no east arm: the strip shows; one with it hides it.
assert!(!ctm.stacked_pane_hides(0, 1, east));
assert!(ctm.stacked_pane_hides(1, 0, east));
let (south, west) = ([0.5, 0.8], [0.2, 0.5]);
assert!(!ctm.stacked_pane_hides(1, 0, south));
assert!(!ctm.stacked_pane_hides(1, 0, west));
assert!(ctm.stacked_pane_hides(1, 0, north));
// Another block, or a pane without rules, hides nothing.
assert!(!ctm.stacked_pane_hides(1, 2, post));
assert!(!ctm.stacked_pane_hides(3, 3, post));
}
#[test]
fn sprite_names() {
assert_eq!(
@@ -3,8 +3,9 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity,
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+1 -6
View File
@@ -227,12 +227,7 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
| "villager_trade" | "dialog" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -623,6 +623,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
Some("data_maps/mymod:item/fuel")
);
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
@@ -13,7 +13,8 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
/// Camera-relative position and radius; RGB intensity in a second vec4.
/// Camera-relative position and radius; RGB intensity in a second vec4. The shaders read it as a flat
/// `array<vec4<f32>, 32>` (nested arrays need ARB_arrays_of_arrays, absent from GL below 4.3).
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
@@ -75,6 +76,12 @@ impl Luminance {
self.0.get(item).copied().unwrap_or(0)
}
/// The HDR tier of an item that glows, as its block's (`mesher::hdr_tier`), 0 for the rest.
pub fn hdr_tier(&self, item: &str) -> u8 {
let name = item.strip_prefix("minecraft:").unwrap_or(item);
crate::mesher::hdr_tier(name, self.of(item)) & !crate::mesher::WHOLE
}
#[cfg(test)]
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
@@ -118,6 +125,23 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
#[test]
fn items_shine_as_their_blocks() {
use crate::mesher::{BRIGHT, INCANDESCENT};
let lum = Luminance::from_pairs(&[
("minecraft:torch", 14),
("minecraft:glowstone", 15),
("minecraft:lava_bucket", 15),
("minecraft:campfire", 15),
]);
assert_eq!(lum.hdr_tier("minecraft:torch"), INCANDESCENT);
assert_eq!(lum.hdr_tier("minecraft:glowstone"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:lava_bucket"), INCANDESCENT);
// Items carry no sprite thresholds: only the tier, not "all over".
assert_eq!(lum.hdr_tier("minecraft:campfire"), BRIGHT);
assert_eq!(lum.hdr_tier("minecraft:apple"), 0);
}
#[test]
fn colored_sources_are_camera_relative_and_bounded() {
let lights = select_colored(
+48 -18
View File
@@ -22,12 +22,15 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_lights: array<vec4<f32>, 32>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_count: vec4<u32>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -58,7 +61,8 @@ struct Out {
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour), or the
// dissolve mask's atlas UV for overlay 38.
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
@@ -97,8 +101,8 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
let at = globals.colored_lights[i * 2u];
let color = globals.colored_lights[i * 2u + 1u];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
@@ -252,7 +256,15 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
let texel = textureSample(atlas, atlas_sampler, uv);
var tint = select(in.back_color, in.color, front);
if in.overlay == 38u {
// RenderPipelines.ENTITY_CUTOUT_DISSOLVE (entity.fsh's DISSOLVE): gone where the vertex
// alpha is below the mask's (its atlas UV in glint_uv), the rest drawn opaque.
if tint.a < textureSampleLevel(atlas, atlas_sampler, in.glint_uv, 0.0).a { discard; }
tint.a = 1.0;
}
var color = texel * tint;
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
@@ -315,8 +327,28 @@ fn shadow_color(in: Out) -> vec4<f32> {
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
let texel = textureSample(atlas, atlas_sampler, uv);
if in.tier == 0u || texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
}
// HDR output's mark (hdr.wgsl): the frame's alpha below 0.75, a band of 0.175 per
// tier (1 low .. 4 very bright, from 0.525 down to 0) and in it how much the pixel
// lifts, from its own brightness `y` before lighting through the tier's window.
fn hdr_mark(tier: u32, y: f32) -> f32 {
let t = clamp(tier, 1u, 4u) - 1u;
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
// The hand in HDR output (gfx.rs "HDR hand marks"): where it drew, what the world kept there
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -334,19 +366,17 @@ fn celestial_color(in: Out) -> vec4<f32> {
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
// premultiplied for an additive (one, one) blend. Where a texel adds light, its
// alpha leaves the sky's 1 at HDR output's mark (hdr_mark) for the brightest
// tier, so the sun's and moon's discs shine; the dark rest of their quads and
// the dim End sky stay unmarked.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
// geometry that sets none (the End sky, the End flash): it stays brightest.
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
// none (the End sky, the End flash) stays brightest.
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
+42 -18
View File
@@ -5,6 +5,7 @@
use crate::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
camera::NearPlane,
environment::Sky,
world::World,
};
@@ -135,9 +136,24 @@ fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option
};
Some((s.fluid, height))
}
/// `Camera.getFluidInCamera` (near-plane points reduced to the camera and
/// the point just in front of it).
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f64; 3]) -> FogType {
/// `Camera.getFluidInCamera`: water at the camera, then lava or powder snow
/// at the near plane's centre and corners.
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], near: &NearPlane) -> FogType {
camera_fog_type(
camera,
near,
|b| camera_fluid_height(world, facts, b),
|b| &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow",
)
}
/// `fog_type` over a block lookup: the fluid and its camera height, and
/// whether the block is powder snow.
fn camera_fog_type(
camera: [f64; 3],
near: &NearPlane,
fluid: impl Fn([i32; 3]) -> Option<(Fluid, f64)>,
powder_snow: impl Fn([i32; 3]) -> bool,
) -> FogType {
let block = |p: [f64; 3]| {
[
p[0].floor() as i32,
@@ -146,28 +162,19 @@ pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f
]
};
let b = block(camera);
if let Some((Fluid::Water, h)) = camera_fluid_height(world, facts, b)
if let Some((Fluid::Water, h)) = fluid(b)
&& camera[1] < f64::from(b[1]) + h
{
return FogType::Water;
}
for p in [
[
camera[0] + forward[0] * 0.05,
camera[1] + forward[1] * 0.05,
camera[2] + forward[2] * 0.05,
],
camera,
] {
for point in near.points() {
let p: [f64; 3] = std::array::from_fn(|i| camera[i] + point[i]);
let b = block(p);
match camera_fluid_height(world, facts, b) {
match fluid(b) {
Some((Fluid::Lava, h)) if p[1] <= f64::from(b[1]) + h => return FogType::Lava,
Some((Fluid::Lava, _)) => {}
_ => {
if &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow" {
return FogType::PowderSnow;
}
}
_ if powder_snow(b) => return FogType::PowderSnow,
_ => {}
}
}
FogType::Atmospheric
@@ -448,6 +455,23 @@ mod tests {
}
}
#[test]
fn lava_fog_starts_when_a_near_plane_corner_dips_under_the_surface() {
// Flowing lava (amount 4) on y 64: the camera sees its surface at 64 + 4/9.
let surface = 64.0 + 4.0 / 9.0;
let fluid = |b: [i32; 3]| (b[1] == 64).then_some((Fluid::Lava, 4.0 / 9.0));
let fog = |y: f64, pitch: f32| {
let mut camera = crate::camera::Camera::default();
camera.set_rotation(0.0, pitch);
let near = camera.near_plane(70.0, 1600.0, 900.0);
camera_fog_type([0.5, y, 0.5], &near, fluid, |_| false)
};
// Looking level, the near plane reaches tan(35 degrees) * 0.05 = 0.035 below the eye.
assert_eq!(fog(surface + 0.02, 0.0), FogType::Lava);
assert_eq!(fog(surface + 0.05, 0.0), FogType::Atmospheric);
// The eye itself is not tested: looking up, the whole plane is above it.
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
}
#[test]
fn boss_world_fog_closes_in() {
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
let sky = Sky {
File diff suppressed because it is too large. Load diff
@@ -735,7 +735,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
+282 -10
View File
@@ -100,6 +100,9 @@ impl Page {
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// A page of at least this many bytes shrinks (`Arena::oversized`) when a page
/// of its ranges in use halves it or is `PAGE_BYTES` smaller.
const SHRINK_MIN_BYTES: u64 = 64 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
@@ -139,10 +142,10 @@ impl Arena {
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
}
/// Translucent quads' vertices, which no shader reads as storage: pages
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
/// it (`Renderer::compact_arena`): a page this large has no others to
/// empty into, and the compaction moves sections' `page` ranges, not their
/// `translucent_at`.
/// up to `LARGE_PAGE_BYTES`. A page this large has no others to empty into
/// (`sparsest`): once the view has settled, one far larger than its ranges
/// in use moves into a new page of their size (`oversized`, `open_page`;
/// `Renderer::shrink_translucent`).
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
let usage = wgpu::BufferUsages::VERTEX;
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
@@ -241,7 +244,7 @@ impl Arena {
})
}
/// The rounded length a request of `vertices` occupies.
fn span(vertices: u64) -> u64 {
pub fn span(vertices: u64) -> u64 {
vertices.max(1).next_multiple_of(GRAIN)
}
/// Bytes of every page's buffer.
@@ -252,6 +255,18 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
/// submitted, most of a page just made or grown.
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
let p = self.pages[page as usize].as_ref().expect("live arena page");
let end = match p.free.0.last_key_value() {
Some((&start, &len)) if start + len == p.capacity => start,
_ => p.capacity,
};
// A page a draw names holds a range; wgpu refuses an empty slice.
p.buffer.slice(..end.max(GRAIN) * self.stride)
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
@@ -266,6 +281,15 @@ impl Arena {
.expect("live arena page")
.buffer
}
/// Bytes of one vertex (or index).
pub fn stride(&self) -> u64 {
self.stride
}
/// Vertices in `page`'s ranges in use (as `span` rounds them).
pub fn used(&self, page: u32) -> u64 {
let page = self.pages[page as usize].as_ref().expect("live arena page");
page.capacity - page.free_vertices()
}
/// A range of `vertices`: (page, first vertex). Grows the newest page
/// (copying its contents on the GPU, in `encoder`) or starts a new one
/// when nothing free is large enough; `None` when no memory is left for
@@ -390,6 +414,49 @@ impl Arena {
.min()
.map(|(_, _, i)| i)
}
/// A page far larger than its ranges in use, and the capacity a page of them
/// gets (`grown`: twice theirs, or `PAGE_BYTES` more; at least the first
/// page's): one of `SHRINK_MIN_BYTES` or more, with ranges in use (an empty
/// one is `trim`'s), that this halves or makes `PAGE_BYTES` smaller. In
/// system memory too (`open_page` makes the new one in video memory). None
/// while a page is closing; the page it leads to is not oversized until its
/// ranges change.
pub fn oversized(&self) -> Option<(u32, u64)> {
if self.closing.is_some() {
return None;
}
let saving = PAGE_BYTES / self.stride;
self.pages.iter().enumerate().find_map(|(i, p)| {
let p = p.as_ref()?;
let used = p.capacity - p.free_vertices();
let capacity = self
.grown(used, 0)
.max(self.initial_capacity)
.next_multiple_of(GRAIN)
.min(self.page_limit);
let smaller = 2 * capacity <= p.capacity || capacity + saving <= p.capacity;
(p.capacity * self.stride >= SHRINK_MIN_BYTES && used > 0 && smaller)
.then_some((i as u32, capacity))
})
}
/// A new, empty page of `capacity` vertices in video memory, made the newest
/// (the one that grows), for what `Renderer::shrink_translucent` moves off an
/// `oversized` page (`alloc_moved`) and for new ranges meanwhile. False, and
/// nothing changes, when video memory has no room for it: in system memory it
/// would slow every frame that draws it.
pub fn open_page(&mut self, device: &wgpu::Device, capacity: u64) -> bool {
let Some((buffer, host)) = self.create(device, capacity, false) else {
return false;
};
let free = FreeList::new(capacity);
self.pages.push(Some(Page {
buffer,
capacity,
free,
host,
}));
true
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
@@ -526,7 +593,7 @@ mod tests {
/// A device with those of `features` the adapter has.
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("arena GPU regression: {}", adapter.get_info().name);
let required_features = adapter.features() & features;
@@ -540,7 +607,7 @@ mod tests {
/// A device with the adapter's own limits, as the renderer opens it: page
/// caps past the default 256 MiB buffer limit show there.
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
@@ -678,7 +745,7 @@ mod tests {
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{SectionGpu, move_off_page};
use super::super::{Part, SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -744,14 +811,15 @@ mod tests {
arena.close(0);
let mut encoder = device.create_command_encoder(&Default::default());
let meshes = sections.iter_mut();
let mut budget = u64::MAX;
assert!(move_off_page(
&mut arena,
&mut encoder,
None,
meshes,
0,
split,
u64::MAX
Part::Section { split },
&mut budget
));
queue.submit([encoder.finish()]);
let word =
@@ -784,6 +852,32 @@ mod tests {
}
}
/// 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.
#[test]
fn large_pages_are_bound_up_to_their_last_range_in_use() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
let ranges: Vec<_> = (0..3)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
// The third grain grew the page to four.
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
// A free range before the last in use stays bound, none after it; an empty
// page binds a grain (wgpu refuses an empty slice).
arena.release(0, ranges[1].1, GRAIN);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
arena.release(0, ranges[2].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
arena.release(0, ranges[0].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -916,6 +1010,184 @@ mod tests {
);
}
/// A large page far larger than its ranges in use moves into a new page of their
/// size (`begin_shrink`) over frames, through `move_off_page`: a budget a call,
/// its first range whatever its size, data and all, while new ranges, writes and
/// releases go on around the moves in one encoder, as the renderer records them.
/// The other range of each mesh stays; the emptied page takes no new range until
/// `trim` returns it. Translucent vertices and sorted indices alike.
#[test]
fn a_large_page_moves_into_one_of_the_size_its_ranges_get() {
use super::super::{Part, SectionGpu, begin_shrink, move_off_page};
// Where system memory could take the new page, it must not.
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
eprintln!("no GPU adapter: skipped");
return;
};
for (mut arena, part) in [
(Arena::translucent(&device, 24, GRAIN), Part::Translucent),
(Arena::translucent_indices(&device, GRAIN), Part::Indices),
] {
let indices = matches!(part, Part::Indices);
let stride = arena.stride;
// 64 quads a mesh: their vertices, or their sorted indices.
let len: u64 = if indices { 64 * 6 } else { 64 * 4 };
let span = len.next_multiple_of(GRAIN);
// Its range here at `at`; the other one names a page that is not there.
let mesh = |at: (u32, u64), solid: u32, translucent: u32| SectionGpu {
page: 0,
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
};
let mut uploads = super::super::upload::Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
// As the renderer writes: through a belt, in the encoder's order.
let mut write = |encoder: &mut _, buffer: &wgpu::Buffer, at: u64, value: u32| {
uploads.buffer(encoder, buffer, at * stride, &value.to_le_bytes())
};
// Sixteen meshes with their number at both ends of their range, and one
// of solid quads alone (nothing here).
let mut meshes: Vec<(u32, SectionGpu)> = (0..16)
.map(|i| {
let at = arena.alloc(&device, &mut encoder, len).unwrap();
write(&mut encoder, arena.buffer(at.0), at.1, i);
write(&mut encoder, arena.buffer(at.0), at.1 + len - 1, i);
(i, mesh(at, 0, 64))
})
.collect();
meshes.push((99, mesh((0, 0), 64, 0)));
// Grown past `SHRINK_MIN_BYTES` in the same encoder (the writes before
// reach the grown buffer), then mostly freed, as after a teleport.
let big = (64u64 << 20).div_ceil(stride);
let at = arena.alloc(&device, &mut encoder, big).unwrap();
arena.release(at.0, at.1, big);
for (_, s) in meshes.extract_if(.., |(i, _)| ![0, 3, 6, 9, 12, 99].contains(i)) {
let (page, start, n) = part.of(&s).unwrap();
arena.release(page, start, n);
}
// No video memory for the new page: nothing changes, nothing spills.
arena.video_memory_full = true;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(false));
assert_eq!((arena.closing(), arena.pages.len()), (None, 1));
assert!(!arena.spilled);
arena.video_memory_full = false;
let held = meshes.iter_mut().map(|(_, s)| s);
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(true));
assert_eq!(arena.closing(), Some(0));
// Twice the five meshes' ranges.
assert_eq!(arena.pages[1].as_ref().unwrap().capacity, 10 * span);
// New ranges go to the new page meanwhile.
let upload = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
assert_eq!(upload.0, 1);
write(&mut encoder, arena.buffer(1), upload.1, 77);
// Moves what page 0 holds within `budget`: how many meshes page 1 has then.
let moved = |arena: &mut Arena,
encoder: &mut _,
meshes: &mut [(u32, SectionGpu)],
budget: &mut u64| {
let held = meshes.iter_mut().map(|(_, s)| s);
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()
};
let mut budget = 2 * len * stride;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!((on, budget), (2, 0));
// Out of budget, a call still moves its first range.
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 3);
// The sorter writes where #0 is now; a mesh still on the old page goes.
let (page, start, _) = part.of(&meshes[0].1).unwrap();
write(&mut encoder, arena.buffer(page), start, 100);
let gone = meshes
.iter()
.position(|(_, s)| part.of(s).is_some_and(|r| r.0 == 0));
let (page, start, n) = part.of(&meshes.remove(gone.unwrap()).1).unwrap();
arena.release(page, start, n);
budget = u64::MAX;
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
assert_eq!(on, 4);
// Emptied, it stays closed until `trim`.
assert_eq!((arena.used(0), arena.closing()), (0, Some(0)));
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
let word = |page, at: u64| read(&device, &queue, arena.buffer(page), at * stride);
for (i, s) in &meshes {
let Some((page, start, n)) = part.of(s) else {
// Solid quads alone: as it was.
let at = if indices { (7, 7) } else { (0, 0) };
assert_eq!((*i, s.translucent_at, s.sorted_indices), (99, at, None));
continue;
};
assert_eq!(page, 1);
assert_eq!(word(page, start), if *i == 0 { 100 } else { *i });
assert_eq!(word(page, start + n - 1), *i);
// Its other range stays where it was.
assert!(s.translucent_at == (7, 7) || s.sorted_indices == Some((7, 7)));
}
assert_eq!(word(upload.0, upload.1), 77);
arena.trim();
assert!(!arena.has_page(0));
assert_eq!(arena.closing(), None);
assert_eq!(arena.bytes(), 10 * span * stride);
}
}
/// Large pages shrink when a page of their ranges in use (what `grown` makes of
/// them, at least the first page's size) halves them or is `PAGE_BYTES`
/// smaller: at the smallest page that shrinks and at the largest.
#[test]
fn large_pages_shrink_when_that_halves_them_or_saves_a_page() {
let Some((device, _)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, 1 << 18);
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, 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();
arena.pages = vec![Some(Page {
buffer,
capacity,
free,
host,
})];
arena.oversized().map(|(_, to)| to)
};
let least = (64u64 << 20).div_ceil(24).next_multiple_of(GRAIN);
let quarter = least / 4 / GRAIN * GRAIN;
assert_eq!(shrinks(least, quarter, false), Some(2 * quarter));
assert_eq!(shrinks(least, quarter + GRAIN, false), None);
let used = (limit - 2 * step) / GRAIN * GRAIN;
let target = (used + step).next_multiple_of(GRAIN);
assert_eq!(shrinks(limit, used, false), Some(target));
assert_eq!(shrinks(limit, used + GRAIN, false), None);
// The page it leads to, holding the same, is not oversized.
assert_eq!(shrinks(target, used, false), None);
// Not below the first page's size; smaller pages and empty ones stay.
assert_eq!(shrinks(least, GRAIN, false), Some(1 << 18));
assert_eq!(shrinks(least - GRAIN, GRAIN, false), None);
assert_eq!(shrinks(least, 0, false), None);
// One in system memory goes back to video memory; none while one closes.
assert_eq!(shrinks(least, quarter, true), Some(2 * quarter));
arena.closing = Some(0);
assert_eq!(arena.oversized(), None);
}
#[test]
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
let Some((device, queue)) = device() else {
@@ -4,7 +4,7 @@ use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn crack_samples_stay_aligned_between_stages() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
@@ -37,7 +37,7 @@ fn geometry_matches_pre_residency_vertex_snapshots() {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
eprintln!("End sky test adapter: {:?}", adapter.get_info());
pollster::block_on(adapter.request_device(&Default::default())).ok()
@@ -22,6 +22,10 @@ const MATERIALS: usize = 10;
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// `RenderPipelines.ENTITY_CUTOUT_DISSOLVE` (the dying dragon): a pixel is dropped where the
/// vertex alpha is below the mask's, whose atlas UV rides in `Vertex::glint_uv`; the rest is
/// drawn opaque. No overlay.
pub const DISSOLVE_OVERLAY: u32 = 38;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -173,7 +173,8 @@ impl EntityOutline {
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
/// 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.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
@@ -184,6 +185,7 @@ impl EntityOutline {
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.targets = None;
return;
}
let vertices = silhouettes.len() as u64;
+64 -4
View File
@@ -42,6 +42,8 @@ struct Plan {
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
pass_calls: [usize; 2],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -107,6 +109,9 @@ pub struct Flat {
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Metal's two solid passes over phase 1: the opaque one draws
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
pub pass_calls: [usize; 2],
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
@@ -216,7 +221,8 @@ fn buffer(
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own.
/// 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.
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -224,6 +230,7 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
@@ -234,6 +241,7 @@ fn plan(
segments.clear();
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 by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
@@ -248,7 +256,13 @@ fn plan(
shadow_calls[k].get_or_insert(calls.len());
}
}
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
for (k, &from) in passes.iter().enumerate() {
if i == from {
pass_calls[k].get_or_insert(calls.len());
}
}
let starts =
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -283,10 +297,12 @@ fn plan(
ranges.resize(padded as usize, [0; 4]);
}
let solid_calls = solid_calls.unwrap_or(calls.len());
let phase1_calls = phase1_calls.unwrap_or(calls.len()).min(solid_calls);
Plan {
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
phase1_calls,
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
total,
}
}
@@ -438,6 +454,7 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
pass_calls: [0; 2],
shadow_calls: [0; 2],
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
@@ -519,6 +536,7 @@ impl Flat {
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
@@ -563,6 +581,7 @@ impl Flat {
phase1_calls,
solid_calls,
shadow_calls,
pass_calls,
total,
} = plan(
draws,
@@ -570,6 +589,7 @@ impl Flat {
phase1_draws,
solid_draws,
shadow,
passes,
self.range_align,
&mut self.calls,
&mut self.range_data,
@@ -578,11 +598,13 @@ impl Flat {
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
self.shadow_calls = shadow_calls;
self.pass_calls = pass_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
self.pass_calls = [0; 2];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -870,6 +892,7 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
@@ -904,6 +927,39 @@ mod tests {
assert_eq!(ranges.len(), 12);
}
/// Metal's opaque pass draws the lists before `passes[0]`, its cutout
/// pass those from `passes[1]`: each bound starts a call of its own,
/// though its page goes on.
#[test]
fn plan_splits_calls_where_the_solid_passes_end_and_begin() {
let (draws, pages, solid) = sample();
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
solid,
solid,
[draws.len(); 2],
[3, 1],
4,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
calls,
[
call(0, 0, 18),
call(0, 18, 12),
call(1, 30, 6),
call(0, 36, 24),
call(0, 60, 18)
]
);
}
/// A shader pack's shadow draws follow the camera's, in calls of their
/// own even from the same page: solid ones, then translucent ones.
#[test]
@@ -919,6 +975,7 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
4,
&mut calls,
&mut ranges,
@@ -946,6 +1003,7 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
@@ -957,6 +1015,7 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
total: 6 * 13
}
);
@@ -982,7 +1041,7 @@ mod tests {
/// of the draws the test saw, in order, and their calls count them.
#[test]
fn flatten_writes_the_indices_of_the_per_section_draws() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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;
@@ -1061,6 +1120,7 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
align,
&mut calls,
&mut ranges,
@@ -470,7 +470,7 @@ pub(super) mod tests {
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
+24 -10
View File
@@ -61,7 +61,8 @@ struct Targets {
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
@@ -268,7 +269,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 96,
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -319,7 +320,10 @@ impl Hdr {
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let world = texture(
"Horizon HDR world",
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
@@ -437,7 +441,19 @@ impl Hdr {
pass.draw(0..3, 0..1);
}
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
/// reads for it too), and what tints the world rather than covering it.
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
let t = self.targets.as_ref()?;
Some(t.world.create_view(&Default::default()))
}
/// Writes the finished frame to the swapchain image `target`.
/// The tiers' windows for the shaders that mark the frame (`Globals::hdr_windows`):
/// where each starts, then where each is full.
pub fn windows(&self) -> [[f32; 4]; 2] {
[self.tiers.map(|t| t.start), self.tiers.map(|t| t.full)]
}
pub fn present(
&self,
queue: &wgpu::Queue,
@@ -510,10 +526,10 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 24];
let mut params = [0.0; 16];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -525,12 +541,10 @@ impl Hdr {
params[6] = self.ui.clamp(0.25, 1.0);
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
params[12 + i] = t.start;
params[16 + i] = t.full;
}
params[20] = c.map_or(0.0, |c| c.step as f32);
params[21] = c.map_or(0.0, |c| c.nits);
params[22] = c.map_or(0.0, |c| c.band);
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
params
}
@@ -0,0 +1,159 @@
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
//! An ICC profile has no values above white, so those programs show a correct SDR picture
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
const WHITE: f64 = 203.0;
/// PQ's values from 0 to 1, in the curve's steps.
const STEPS: usize = 4096;
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
fn pq_nits(signal: f64) -> f64 {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = signal.powf(1.0 / m2);
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut tag = b"XYZ \0\0\0\0".to_vec();
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
tag
}
fn curve() -> Vec<u8> {
let mut tag = b"curv\0\0\0\0".to_vec();
tag.extend((STEPS as u32).to_be_bytes());
for i in 0..STEPS {
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
}
tag
}
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
fn description(text: &str) -> Vec<u8> {
let mut tag = b"desc\0\0\0\0".to_vec();
tag.extend((text.len() as u32 + 1).to_be_bytes());
tag.extend(text.as_bytes());
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
tag
}
/// An ICC v2 matrix-shaper display profile.
pub(super) fn profile() -> Vec<u8> {
let trc = curve();
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
let tags: [(&[u8; 4], &[u8]); 9] = [
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
(b"cprt", &copyright),
(b"wtpt", &xyz(D50)),
(b"rXYZ", &xyz(RED)),
(b"gXYZ", &xyz(GREEN)),
(b"bXYZ", &xyz(BLUE)),
(b"rTRC", &trc),
(b"gTRC", &trc),
(b"bTRC", &trc),
];
let data_start = 128 + 4 + 12 * tags.len();
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc_at = None;
for (name, tag) in tags {
// The three channels share one curve.
let is_trc = tag == &trc[..];
let at = match trc_at.filter(|_| is_trc) {
Some(at) => at,
None => {
let at = data_start + data.len();
data.extend(tag);
data.resize(data.len().next_multiple_of(4), 0);
at
}
};
if is_trc {
trc_at = Some(at);
}
table.extend(name);
table.extend((at as u32).to_be_bytes());
table.extend((tag.len() as u32).to_be_bytes());
}
let mut out = vec![0; 128];
let size = data_start + data.len();
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
out[12..16].copy_from_slice(b"mntr");
out[16..20].copy_from_slice(b"RGB ");
out[20..24].copy_from_slice(b"XYZ ");
out[36..40].copy_from_slice(b"acsp");
// Relative colorimetric, and the connection space's illuminant.
out[64..68].copy_from_slice(&1u32.to_be_bytes());
for (i, &c) in D50.iter().enumerate() {
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
}
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
let at = (0..u32_at(128))
.map(|i| 132 + 12 * i)
.find(|&e| &profile[e..e + 4] == name)
.unwrap();
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
}
#[test]
fn the_profile_is_whole() {
let p = profile();
assert_eq!(
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
p.len()
);
assert_eq!(&p[36..40], b"acsp");
assert_eq!(p.len() % 4, 0);
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
// One curve, shared: the profile stays small.
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
assert!(p.len() < 10_000, "{}", p.len());
}
#[test]
fn the_primaries_add_up_to_white() {
for i in 0..3 {
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
}
}
#[test]
fn the_curve_reaches_white_at_reference_white_and_stays() {
let p = profile();
let trc = tag(&p, b"rTRC");
let value = |signal: f64| {
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
u16::from_be_bytes([trc[i], trc[i + 1]])
};
assert_eq!(value(0.0), 0);
// 203 nits in PQ is a signal of 0.5807.
assert!(value(0.57) < 65535);
assert_eq!(value(0.59), 65535);
assert_eq!(value(1.0), 65535);
}
}
@@ -217,7 +217,7 @@ fn long_flash_sequence_keeps_only_active_seeds() {
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn retained_gpu_draw_matches_transient_additive_draw() {
use bytemuck::Zeroable;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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;
@@ -530,7 +530,7 @@ fn white_uv_is_inside_the_white_sprite() {
#[test]
#[ignore = "requires GPU; run separately with --ignored --exact"]
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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;
@@ -276,6 +276,10 @@ impl Renderer {
/// once all are in, fades them in over the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
if self.lod_pending.is_some() {
// Far tiles fill the arenas too: large translucent pages wait for them.
self.shrink_gate.wait(start, super::SHRINK_SETTLE);
}
self.release_left(start);
let faded: Vec<_> = self
.lods
@@ -1294,7 +1294,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
@@ -211,7 +211,7 @@ mod tests {
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
#[test]
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -244,7 +244,7 @@ mod tests {
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
#[test]
fn layers_land_in_their_cells() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -0,0 +1,296 @@
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
//! target, copied to one of a few small buffers and read back frames later without waiting for
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
const SLOTS: usize = 3;
const FREE: u8 = 0;
/// Copied this frame: mapped once the frame is submitted.
const COPIED: u8 = 1;
const PENDING: u8 = 2;
const MAPPED: u8 = 3;
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// The 1x1 target the centre texel is loaded into.
texel: wgpu::Texture,
texel_view: wgpu::TextureView,
/// The bind group of the depth texture it reads, made again when that texture changes.
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
/// The depth last read back: GL window depth, 0..1.
latest: Option<f32>,
}
impl CenterDepth {
pub fn new(device: &wgpu::Device) -> Self {
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack centre depth"),
size: u64::from(ROW),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack centre depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack centre depth"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack centre depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let format = wgpu::TextureFormat::R32Float;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack centre depth"),
layout: Some(&pipeline_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(format.into())],
}),
multiview_mask: None,
cache: None,
});
let texel = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack centre depth"),
size: wgpu::Extent3d {
width: 1,
height: 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::COPY_SRC,
view_formats: &[],
});
let texel_view = texel.create_view(&Default::default());
Self {
slots,
pipeline,
layout,
texel,
texel_view,
group: None,
latest: None,
}
}
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
pub fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::Texture,
) {
let Some(slot) = self
.slots
.iter()
.find(|s| s.state.load(Ordering::Acquire) == FREE)
else {
return;
};
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
let view = depth.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack centre depth"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.group = Some((depth.clone(), group));
}
let Some((_, group)) = &self.group else {
return;
};
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack centre depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.texel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
self.texel.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &slot.buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(ROW),
rows_per_image: Some(1),
},
},
self.texel.size(),
);
slot.state.store(COPIED, 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.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.ok()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
if let Some(depth) = depth.filter(|d| d.is_finite()) {
self.latest = Some(depth.clamp(0.0, 1.0));
}
}
self.latest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// 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]
fn the_centre_depth_comes_back() {
let Some((device, queue)) = test_device() else {
return;
};
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 9,
height: 7,
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);
assert_eq!(center.collect(&device), None);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.375),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
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.
for _ in 0..SLOTS {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
}
}
@@ -512,6 +512,8 @@ pub struct PackPost {
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -2242,6 +2244,7 @@ impl PackPost {
block_ids,
page_groups: HashMap::new(),
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2740,6 +2743,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -2896,6 +2906,13 @@ impl PackPost {
}
}
/// After the frame's submission: the centre depth it copied starts its way back.
pub fn submitted(&mut self) {
if let Some(center) = &mut self.center_depth {
center.submitted();
}
}
/// Whether the pack draws entities or block entities itself.
pub fn has_entities(&self) -> bool {
self.entities.is_some()
@@ -3613,6 +3630,10 @@ impl PackPost {
if let Some(t) = &mut self.timing {
t.begin(device, queue);
}
self.uniforms.center_depth_raw = self
.center_depth
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
@@ -3851,6 +3872,10 @@ impl PackPost {
self.draw_translucent(device, queue, encoder, size, terrain);
}
let targets = self.targets.as_ref().expect("resized");
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
if let Some(center) = &mut self.center_depth {
center.copy(device, encoder, &targets.depth_texture);
}
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
@@ -3949,7 +3974,7 @@ mod tests {
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
#[test]
fn a_shader_compiler_panic_skips_the_pass() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: 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.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
pub center_depth_raw: Option<f32>,
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -379,6 +439,15 @@ impl FrameUniforms {
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*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, 1.0 / 20.0, dt),
None => raw,
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
@@ -424,6 +493,8 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +554,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +600,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +658,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +688,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
@@ -16,6 +16,8 @@
/// The width of a band, in blocks.
const BAND: f64 = 64.0;
/// Where a run's group sits in its sort key, above band and page.
const GROUP_SHIFT: u32 = 62;
/// One section's draws: `start..start + len` of the list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,10 +43,24 @@ impl PageRuns {
/// The draws `start..start + len` of a section `distance` from the
/// camera (squared, as bits) in arena `page`; runs come nearest first.
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
self.push_in(0, distance, page, start, len, tag);
}
/// As `push`, in `group` (0 to 3): regrouping puts each group's runs
/// before the next group's, then bands and pages within it.
pub fn push_in(
&mut self,
group: u8,
distance: u64,
page: u32,
start: usize,
len: usize,
tag: [u32; 3],
) {
if len > 0 {
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
self.runs.push(Run {
key: band << 32 | u64::from(page),
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
len: len as u32,
tag,
@@ -61,7 +77,8 @@ impl PageRuns {
pages: &mut [(u32, Option<u32>)],
base: usize,
) -> &[Run] {
let page = |r: &Run| r.key as u32;
// Group and page: one of each needs no reordering.
let page = |r: &Run| r.key & (3 << GROUP_SHIFT | u64::from(u32::MAX));
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
return &self.runs;
}
@@ -157,6 +174,24 @@ mod tests {
assert_eq!(order, [0, 1, 2]);
}
/// Metal's two solid passes draw a prefix and a suffix of the list:
/// groups come whole and in order, whatever their distance and page.
#[test]
fn groups_come_before_bands_and_pages() {
let mut grouped = PageRuns::default();
let mut draws: Vec<_> = (0..5).map(|n| [n, 0, 0, 0, 0]).collect();
let mut pages = vec![(0, None); 5];
for (n, (group, distance)) in [(2, 10.0), (0, 300.0), (1, 20.0), (0, 30.0), (2, 5.0)]
.into_iter()
.enumerate()
{
grouped.push_in(group, bits(distance), 0, n, 1, [0; 3]);
}
grouped.regroup(&mut draws, &mut pages, 0);
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
assert_eq!(order, [3, 1, 2, 0, 4]);
}
#[test]
fn one_page_and_an_offset_list() {
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
+267 -77
View File
@@ -1,36 +1,64 @@
//! Vanilla 26.3's CubeMapTexture and CubeMap, separate from the pixel-filtered GUI atlas.
use crate::{Result, hud::decode};
use crate::{Result, hud::decode_bytes};
use glam::Mat4;
use std::path::Path;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use wgpu::util::DeviceExt;
/// Native cube order: +X, -X, +Y, -Y, +Z, -Z (CubeMapTexture.SUFFIXES).
const FACE_ORDER: [u8; 6] = [1, 3, 5, 4, 0, 2];
/// Where the cube's faces live in a resource root.
const BACKGROUND: &str = "assets/minecraft/textures/gui/title/background";
/// The title screen's six faces. The cube is on the GPU only while the title
/// screen shows, and its texels are decoded from the faces' files each time it
/// is made: kept decoded, they held 24 MiB through the whole game.
#[derive(Debug)]
pub struct Faces {
pub size: u32,
pub pixels: Vec<u8>,
/// The faces' PNG files in cube order; none for a one-colour cube.
files: Vec<Vec<u8>>,
/// The texel of a one-colour cube.
colour: [u8; 4],
/// The texels decoded while loading, which the first cube takes.
decoded: Mutex<Option<Vec<u8>>>,
}
impl Faces {
pub fn load(root: &Path) -> Self {
Self::try_load(root).unwrap_or_else(|error| {
log::warn!("Horizon: title panorama unavailable ({error})");
Self {
size: 1,
pixels: [96, 110, 111, 255].repeat(6),
}
/// The title artwork of `root` (the packs' overlay) over `defaults` (the
/// game's own resources). `CubeMapTexture` registers each face with the
/// resource manager, which takes the topmost pack that has it: a pack that
/// leaves a face alone keeps vanilla's, and only a cube no layer completes
/// falls back to the grey placeholder that an installation without the
/// imported panorama shows.
pub fn load(root: &Path, defaults: &Path) -> Self {
Self::try_load(root, defaults).unwrap_or_else(|error| {
log::warn!(
"Horizon: no title panorama ({error}); prepare the game again in the launcher \
(or run horizon-prepare panorama)"
);
Self::colour([96, 110, 111, 255])
})
}
fn try_load(root: &Path) -> Result<Self> {
let background = root.join("assets/minecraft/textures/gui/title/background");
/// A one-texel cube of one colour.
pub fn colour(colour: [u8; 4]) -> Self {
Self {
size: 1,
files: Vec::new(),
colour,
decoded: Mutex::new(None),
}
}
fn try_load(root: &Path, defaults: &Path) -> Result<Self> {
let mut size = 0;
let mut pixels = Vec::new();
let mut files = Vec::with_capacity(FACE_ORDER.len());
let mut texels = Vec::new();
for face in FACE_ORDER {
let path = background.join(format!("panorama_{face}.png"));
let (w, h, data) = decode(&path)?;
let (path, file, (w, h, data)) = load_face(root, defaults, face)?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"{}: panorama faces must be equally sized squares",
@@ -41,11 +69,104 @@ impl Faces {
size = w;
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
pixels.extend_from_slice(row);
texels.extend_from_slice(row);
}
files.push(file);
}
Ok(Self { size, pixels })
// Decoded once here to check them, and kept for the first cube.
Ok(Self {
size,
files,
colour: [0; 4],
decoded: Mutex::new(Some(texels)),
})
}
/// The cube's texels, layer after layer.
pub fn texels(&self) -> Vec<u8> {
if self.files.is_empty() {
return self.colour.repeat(6);
}
let loaded = self
.decoded
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
loaded
.or_else(|| decode_faces(&self.files).ok().map(|(_, texels)| texels))
.unwrap_or_else(|| vec![0; (self.size * self.size * 4 * 6) as usize])
}
}
/// The faces' files decoded, side by side, into one array of six layers of
/// equal squares.
fn decode_faces(files: &[Vec<u8>]) -> Result<(u32, Vec<u8>)> {
let decoded: Vec<_> = std::thread::scope(|scope| {
let threads: Vec<_> = files
.iter()
.map(|file| scope.spawn(|| decode_bytes(file).map_err(|e| e.to_string())))
.collect();
threads
.into_iter()
.map(|t| t.join().unwrap_or_else(|p| std::panic::resume_unwind(p)))
.collect()
});
let mut size = 0;
let mut texels = Vec::new();
for (face, image) in FACE_ORDER.iter().zip(decoded) {
let (w, h, data) = image?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"panorama_{face}.png: panorama faces must be equally sized squares"
)
.into());
}
if size == 0 {
size = w;
texels.reserve_exact((w * w * 4) as usize * files.len());
}
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
for row in data.chunks_exact((w * 4) as usize).rev() {
texels.extend_from_slice(row);
}
}
Ok((size, texels))
}
/// `panorama_{face}.png` from the first layer that has one, with the path it
/// came from, its file and its decoded image. Reading a face that no layer
/// provides fails naming every path tried, so a report of a missing panorama
/// says where the game looked.
#[allow(clippy::type_complexity)]
fn load_face(
root: &Path,
defaults: &Path,
face: u8,
) -> Result<(std::path::PathBuf, Vec<u8>, (u32, u32, Vec<u8>))> {
let name = format!("panorama_{face}.png");
let mut tried: Vec<(std::path::PathBuf, String)> = Vec::new();
for layer in [root, defaults] {
let path = layer.join(BACKGROUND).join(&name);
// A vanilla-only installation loads one root twice.
if tried.first().is_some_and(|(seen, _)| *seen == path) {
continue;
}
let read = std::fs::read(&path)
.map_err(|e| e.to_string())
.and_then(|file| {
let image = decode_bytes(&file).map_err(|e| e.to_string())?;
Ok((file, image))
});
match read {
Ok((file, image)) => return Ok((path, file, image)),
Err(error) => tried.push((path, error)),
}
}
let tried: Vec<String> = tried
.iter()
.map(|(path, error)| format!("{}: {error}", path.display()))
.collect();
Err(tried.join("; ").into())
}
fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
@@ -62,40 +183,20 @@ fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
pub(super) struct Panorama {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
faces: Arc<Faces>,
/// The cube and its bindings, while the title screen shows.
cube: Option<wgpu::BindGroup>,
}
impl Panorama {
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
faces: &Faces,
faces: Arc<Faces>,
) -> Self {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: faces.size,
height: faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&faces.pixels,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
// CubeMapTexture's blur=true metadata: linear minification and magnification, no mips.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Title panorama linear filtering"),
@@ -140,24 +241,6 @@ impl Panorama {
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Title panorama shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("panorama.wgsl").into()),
@@ -203,13 +286,18 @@ impl Panorama {
});
Self {
pipeline,
group,
layout,
sampler,
uniform,
faces,
cube: None,
}
}
/// Ready a title screen frame: the cube, made if it is not on the GPU.
pub(super) fn prepare(
&self,
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
width: u32,
height: u32,
@@ -221,11 +309,68 @@ impl Panorama {
0,
bytemuck::cast_slice(&projection(width, height, yaw, pitch).to_cols_array()),
);
if self.cube.is_none() {
self.cube = Some(self.cube(device, queue));
}
}
/// Give the cube's video memory back: no frame draws it until the title
/// screen shows again.
pub(super) fn release(&mut self) {
self.cube = None;
}
fn cube(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Title panorama cube"),
size: wgpu::Extent3d {
width: self.faces.size,
height: self.faces.size,
depth_or_array_layers: 6,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&self.faces.texels(),
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::Cube),
..Default::default()
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Title panorama resources"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
/// Draw the cube `prepare` made for this frame.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(cube) = &self.cube else {
return;
};
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(0, cube, &[]);
pass.draw(0..36, 0..1);
}
}
@@ -253,9 +398,18 @@ mod tests {
sync::atomic::{AtomicU32, Ordering},
};
/// One resource root: a pack overlay, or the game's own resources.
struct Fixture(std::path::PathBuf);
impl Fixture {
fn new(size: u32) -> Self {
let fixture = Self::bare();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
/// A root with the faces' folder and none of the faces.
fn bare() -> Self {
static NEXT: AtomicU32 = AtomicU32::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-panorama-{}-{}",
@@ -264,9 +418,6 @@ mod tests {
));
let fixture = Self(root);
fs::create_dir_all(fixture.background()).unwrap();
for face in 0..6 {
fixture.write(face, size, size);
}
fixture
}
fn background(&self) -> std::path::PathBuf {
@@ -309,16 +460,19 @@ mod tests {
#[test]
fn original_resolution_texels_and_vanilla_cube_orientation_are_preserved() {
let f = Fixture::new(1024);
let faces = Faces::try_load(&f.0).unwrap();
let faces = Faces::try_load(&f.0, &f.0).unwrap();
assert_eq!(faces.size, 1024);
assert_eq!(faces.pixels.len(), 6 * 1024 * 1024 * 4);
// The texels decoded while loading, then decoded again for the next cube.
let (loaded, again) = (faces.texels(), faces.texels());
assert_eq!(loaded, again);
assert_eq!(again.len(), 6 * 1024 * 1024 * 4);
// CubeMapTexture's actual layer suffixes, with each source row flipped vertically.
for (layer, source_face) in [1, 3, 5, 4, 0, 2].into_iter().enumerate() {
for y in 0..1024 {
for x in 0..1024 {
let at = ((layer * 1024 + y) * 1024 + x) * 4;
assert_eq!(
faces.pixels[at..at + 4],
again[at..at + 4],
[source_face, (x % 251) as u8, ((1023 - y) % 253) as u8, 255]
);
}
@@ -329,21 +483,57 @@ mod tests {
#[test]
fn placeholders_load_and_incomplete_or_mismatched_cubes_fall_back() {
let f = Fixture::new(1);
assert_eq!(Faces::try_load(&f.0).unwrap().size, 1);
assert_eq!(Faces::try_load(&f.0, &f.0).unwrap().size, 1);
f.write(5, 2, 1);
assert!(
Faces::try_load(&f.0)
Faces::try_load(&f.0, &f.0)
.unwrap_err()
.to_string()
.contains("equally sized squares")
);
let fallback = Faces::load(&f.0);
assert_eq!(fallback.pixels.len(), 24);
let fallback = Faces::load(&f.0, &f.0);
assert_eq!(fallback.texels(), [96, 110, 111, 255].repeat(6));
f.write(5, 2, 2);
assert!(Faces::try_load(&f.0).is_err());
assert!(Faces::try_load(&f.0, &f.0).is_err());
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
assert!(Faces::try_load(&f.0).is_err());
assert_eq!(Faces::load(&f.0).size, 1);
assert!(Faces::try_load(&f.0, &f.0).is_err());
assert_eq!(Faces::load(&f.0, &f.0).size, 1);
}
#[test]
fn a_pack_without_a_face_keeps_vanillas_and_its_own_face_wins() {
let vanilla = Fixture::new(4);
// A pack that replaces one face of the cube: the other five are
// still the game's, as the bottom of the pack stack provides them.
let pack = Fixture::bare();
pack.write(2, 4, 4);
let faces = Faces::try_load(&pack.0, &vanilla.0).unwrap();
assert_eq!(faces.size, 4);
let texels = faces.texels();
assert_eq!(texels.len(), 6 * 4 * 4 * 4);
// Each face's red channel is its index (`face` above), and the cube
// holds them in FACE_ORDER: layer 5 is face 2, the pack's, and layer 4
// is face 0, which only the game's resources have.
let red = |layer: usize, texel: usize| texels[(layer * 16 + texel) * 4];
assert_eq!(red(5, 0), 2);
assert_eq!(red(4, 0), 0);
}
#[test]
fn a_missing_cube_names_every_path_it_tried() {
let vanilla = Fixture::bare();
let pack = Fixture::bare();
let error = Faces::try_load(&pack.0, &vanilla.0)
.unwrap_err()
.to_string();
for root in [&pack, &vanilla] {
let path = root.background().join("panorama_1.png");
assert!(error.contains(&path.display().to_string()), "{error}");
}
// No layer completes the cube: the one-pixel grey placeholder, and the
// window still has a title.
let fallback = Faces::load(&pack.0, &vanilla.0);
assert_eq!((fallback.size, fallback.texels().len()), (1, 24));
}
#[test]
@@ -85,7 +85,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
@@ -172,7 +172,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
@@ -0,0 +1,14 @@
//! The GPU tests' `wgpu::Instance`.
/// A new instance for one GPU test. On Windows it opens Vulkan and DirectX 12,
/// not OpenGL: WGL instances made and dropped from many test threads at once
/// deadlocked NVIDIA's driver, and the render tests hung most runs (#5238).
/// The game runs there on Vulkan or DirectX 12 unless neither works.
/// `WGPU_BACKEND` and the other `WGPU_*` variables still apply, as in the game.
pub(crate) fn instance() -> wgpu::Instance {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if cfg!(windows) {
descriptor.backends = wgpu::Backends::PRIMARY;
}
wgpu::Instance::new(descriptor.with_env())
}
+92 -3
View File
@@ -30,6 +30,8 @@ const DONE: u8 = 2;
/// Resolved and on its way back.
const PENDING: u8 = 3;
const MAPPED: u8 = 4;
/// Readback failed; stop timing rather than reuse an invalid buffer.
const FAILED: u8 = 5;
struct Slot {
buffer: wgpu::Buffer,
@@ -131,6 +133,14 @@ impl GpuTimer {
})
}
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
if self
.slots
.iter()
.any(|slot| slot.state.load(Ordering::Acquire) == FAILED)
{
self.current = None;
return;
}
if self.markers.is_some() {
for slot in 0..SLOTS {
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
@@ -216,7 +226,7 @@ impl GpuTimer {
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
if result.is_ok() { MAPPED } else { FAILED },
Ordering::Release,
);
});
@@ -233,13 +243,21 @@ impl GpuTimer {
}
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
let _ = device.poll(wgpu::PollType::Poll);
if device.poll(wgpu::PollType::Poll).is_err() {
return;
}
for i in 0..SLOTS {
let slot = &self.slots[(self.next + i) % SLOTS];
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
{
let Ok(data) = slot.buffer.slice(..).get_mapped_range() else {
// Device loss can destroy a buffer after its mapping succeeded.
// Unmapping it would turn the failed readback into a panic.
slot.state.store(FAILED, Ordering::Release);
continue;
};
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
// A start marker that ran late (rare on Metal) leaves the frame untimed.
@@ -285,3 +303,74 @@ pub(super) fn end_pipeline(
cache: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn mapped_timer() -> Option<(wgpu::Device, wgpu::Queue, GpuTimer)> {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
// WGL can hang while creating a test instance on Windows (#5238).
descriptor.backends = wgpu::Backends::PRIMARY;
let instance = wgpu::Instance::new(descriptor);
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
if !adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
return None;
}
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_features: GpuTimer::features(&adapter),
..Default::default()
}))
.unwrap();
let mut timer = GpuTimer::new(&device, &queue).unwrap();
queue.write_buffer(
&timer.slots[0].buffer,
0,
bytemuck::cast_slice(&[10_u64, 20]),
);
queue.submit([]);
timer.resolved.push(0);
timer.submitted(&queue);
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), MAPPED);
Some((device, queue, timer))
}
#[test]
fn successful_readback_releases_slot() {
let Some((device, _queue, mut timer)) = mapped_timer() else {
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
return;
};
let mut samples = Vec::new();
timer.collect(&device, &mut samples);
assert_eq!(
samples,
vec![(10.0 * f64::from(timer.period_ns) / 1e6) as f32]
);
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FREE);
}
#[test]
fn destroyed_readback_stops_timing_without_unmapping() {
let Some((device, queue, mut timer)) = mapped_timer() else {
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
return;
};
// Reproduce a buffer destroyed after its successful map callback (#6320).
timer.slots[0].buffer.destroy();
let mut samples = Vec::new();
timer.collect(&device, &mut samples);
assert!(samples.is_empty());
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FAILED);
// Later frames must not resolve or copy into the failed readback buffer.
let mut encoder = device.create_command_encoder(&Default::default());
timer.begin(&mut encoder);
assert!(timer.current.is_none());
assert!(timer.resolved.is_empty());
queue.submit([encoder.finish()]);
timer.submitted(&queue);
timer.collect(&device, &mut samples);
assert!(samples.is_empty());
}
}
@@ -125,7 +125,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
@@ -361,7 +361,7 @@ mod tests {
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn weather_shader_uvs_match_legacy_bits() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let instance = crate::gfx::test_gpu::instance();
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
@@ -568,8 +568,7 @@ mod tests {
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
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()
}
+16 -10
View File
@@ -456,7 +456,7 @@ static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
/// arena's translucent quads).
/// arena's translucent quads). Where it can't, the settings grey its switch out.
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
@@ -601,9 +601,11 @@ pub struct Graphics {
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
/// offered (`translucent_batching_offered`).
/// back-to-front draws go out in a few calls, for a little more video memory. On by
/// default (Translucent Batching, Advanced graphics); read when a renderer is made (start,
/// resource-pack reload), where it is offered (`translucent_batching_offered`). Its key is
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
@@ -646,7 +648,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: false,
translucent_batching: true,
shader_pack: String::new(),
}
}
@@ -739,7 +741,7 @@ impl Graphics {
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -756,7 +758,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -948,7 +950,7 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: true,
translucent_batching: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -968,8 +970,12 @@ mod tests {
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().translucent_batching);
// Translucent Batching is on, in files from before it too; only false turns it off,
// and only under its key: the old one held the false every save wrote while it was
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
@@ -15,7 +15,7 @@ pub enum Cost {
Low,
Medium,
High,
/// Turning it on makes frames faster (culling).
/// Turning it on makes frames faster (culling, caching, batching).
Saves,
}
impl Cost {
@@ -61,6 +61,7 @@ pub enum H {
BatteryMode,
ShadowCache,
GraphicsApi,
TranslucentBatching,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -357,6 +358,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
),
h(
H::TranslucentBatching,
Saves,
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
),
h(
H::UploadBudget,
Low,
@@ -370,11 +376,13 @@ pub fn rows(level: Level) -> Vec<Row> {
),
],
};
// Only Windows and Linux have more than one graphics API.
// Only Windows and Linux have more than one graphics API, and translucent batching
// (Metal draws flat).
if cfg!(target_os = "macos") {
rows.retain(
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
);
rows.retain(|row| {
!matches!(row, Row::Setting(s)
if matches!(s.item, Item::Horizon(H::GraphicsApi | H::TranslucentBatching)))
});
}
rows
}
@@ -623,6 +631,12 @@ impl H {
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -718,6 +732,7 @@ impl H {
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -748,7 +763,8 @@ impl Item {
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -764,6 +780,11 @@ impl Item {
| H::HdrPaperWhite
| H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
_ => None,
}
}
@@ -780,6 +801,7 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -843,6 +865,7 @@ impl Item {
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1468,6 +1491,12 @@ impl GraphicsUi {
&& let Some(target) = self.focus
{
match target {
// A click focuses the greyed row; Enter there does nothing, as the click didn't.
Target::Item(item @ Item::Horizon(H::TranslucentBatching))
if item.blocked(s).is_some() =>
{
return (Vec::new(), true);
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.can_change_value = !self.can_change_value;
@@ -1986,6 +2015,66 @@ mod tests {
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
/// Translucent Batching: a switch on Advanced (not Simple), on by default, no part of a
/// preset, and greyed out, reading Off, where the device can't batch.
#[test]
fn translucent_batching_is_an_advanced_switch_where_the_device_can() {
use winit::keyboard::KeyCode as K;
let item = Item::Horizon(H::TranslucentBatching);
let has = |level| {
rows(level)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == item))
};
if cfg!(target_os = "macos") {
// Metal draws flat: never offered, so not shown.
assert!(!has(Level::Advanced));
return;
}
assert!(has(Level::Advanced) && !has(Level::Simple));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
let w = widgets
.iter()
.find(|w| w.target == Target::Item(item))
.unwrap();
let point = (w.x + 1.0, w.y + 1.0);
let click = |ui: &mut GraphicsUi, s: &mut Settings| {
ui.click(Level::Advanced, s, point, size, [480, 2000], false, false)
};
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it (the click focuses the row) does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert!(click(&mut ui, &mut s).is_empty());
let enter = ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, [480, 2000]);
assert!(enter.0.is_empty());
assert!(s.horizon.translucent_batching);
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(
click(&mut ui, &mut s),
[Action::ButtonClick, Action::Changed]
);
assert!(!s.horizon.translucent_batching);
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(s.horizon.preset, Preset::Balanced);
// No preset sets it.
for on in [false, true] {
s.horizon.translucent_batching = on;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.translucent_batching, on, "{preset:?}");
}
}
}
#[test]
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
use winit::keyboard::KeyCode as K;
+18 -20
View File
@@ -21,11 +21,8 @@ struct Params {
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// the brightest pixels get, and the brightness where the lift starts and
// where it is full.
// its fully lifted pixels get.
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
@@ -40,9 +37,11 @@ struct Params {
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
// `share` of the headroom, dimmer ones less down to none at its `start`
// (lava's crust, a lantern's metal), and no channel goes past the peak.
// Brightness is the mean of luminance and the brightest channel, so saturated
// light counts as bright.
// (lava's crust, a lantern's metal), and no channel goes past the peak. That
// brightness is the texel's own, before lighting: the shaders that mark the
// frame (hdr_mark) work it out, so a light source shines the same at any light
// level and on every face. Brightness is the mean of luminance and the
// brightest channel, so saturated light counts as bright.
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
@@ -157,24 +156,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
// holds, the lower the brighter, and only where nothing was drawn over them
// since: grey text over the white sun still looks like the sun darkened, but
// it raised the frame's alpha.
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
let lift = smoothstep(params.start[tier], params.full[tier], y);
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
&& (hand || (!covered && shows_world(c, w.rgb))) {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
+559 -13
View File
@@ -85,6 +85,10 @@ pub struct HudAtlas {
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
pub random_textures: horizon_optifine::random_textures::RandomTextures,
/// Their emissive overlays (`_e`), packed here too.
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
/// ETF's blinking frames (`_blink`), packed here too.
pub blinks: horizon_optifine::blink::Blinks,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -137,6 +141,34 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
}
Ok((info.width, info.height, rgba))
}
/// The most texels of OptiFine random entity variants the entity atlas packs: 2048 rows of
/// its 4096, which keeps the default atlas (4096x5120) under the 8192 rows many GPUs take.
const RANDOM_TEXELS: u64 = 4096 * 2048;
/// The variant images (and `overlays`) the atlas packs: every variant before any overlay, by
/// name, while their sizes add up to at most `budget` texels. A full budget drops glows before
/// looks.
fn budgeted<'a>(
files: impl Iterator<Item = &'a PathBuf>,
overlays: &HashSet<PathBuf>,
budget: u64,
) -> HashSet<PathBuf> {
let mut files: Vec<&PathBuf> = files.collect();
files.sort_by_key(|p| (overlays.contains(*p), *p));
within_budget(&files, budget)
}
/// The `files` (in order) whose sizes add up to at most `budget` texels.
fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
let mut texels = 0u64;
files
.iter()
.filter(|p| {
let (w, h) = png_size(p).unwrap_or((0, 0));
texels += u64::from(w) * u64::from(h);
texels <= budget
})
.map(|p| (*p).clone())
.collect()
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
@@ -215,6 +247,119 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
/// They are item textures, so an animated strip keeps its first frame. None without
/// `optifine/cit` rules.
fn cit_sprites(
root: &Path,
packed: &[PackedImage],
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
.into_iter()
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
.filter(|id| {
let name = id.strip_prefix("minecraft:").unwrap_or(id);
!have.contains(name) && !name.starts_with("horizon/")
})
.collect();
// No more than the atlas could ever hold, by their headers, so a pack naming thousands of
// big textures is cut short before they are decoded, not after. Each is charged what it
// can be packed as (a strip's frame, or a still image whole), 1024x512 at most since
// `cit_sprite` refuses more, with a gutter;
// the glyph rows and the shelves' waste (a tenth) are kept aside.
let side = u64::from(MAX_ATLAS_SIZE);
let used: u64 = packed
.iter()
.map(|i| u64::from(i.1 + 1) * u64::from(i.2 + 1))
.sum();
let mut left = (side * side * 9 / 10).saturating_sub(side * u64::from(GLYPH_ROWS) + used);
let before = ids.len();
let ids: Vec<String> = ids
.into_iter()
.filter(|id| {
let path = crate::atlas_sources::texture_path(root, id, "textures");
let Some((w, h)) = png_size(&path) else {
return true;
};
let (w, h) = (u64::from(w), u64::from(h));
if w * h > 1024 * 512 * 64 {
// Refused by `cit_sprite` without decoding: costs nothing.
return true;
}
// A strip's frame is at most its width squared; a still image is all of it.
let frame = (w * h).min(w * w).min(1024 * 512) + w + h + 1;
match left.checked_sub(frame) {
Some(rest) => {
left = rest;
true
}
None => false,
}
})
.collect();
if ids.len() < before {
log::warn!(
"left out {} custom item textures: more than the atlas holds",
before - ids.len()
);
}
// Decoded on the threads `pack` decodes the rest on: a big pack names thousands.
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let chunk = ids.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
let workers: Vec<_> = ids
.chunks(chunk)
.map(|ids| scope.spawn(|| ids.iter().filter_map(|id| cit_sprite(root, id)).collect()))
.collect();
workers
.into_iter()
.flat_map(|w| {
w.join().unwrap_or_else(|_| {
log::warn!("a custom item texture decoder panicked; its textures are left out");
Vec::new()
})
})
.collect()
})
}
/// One of `cit_sprites`, or `None` (with a warning) when it does not load or is too big.
fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags>, bool)> {
let name = id.strip_prefix("minecraft:").unwrap_or(id);
let path = crate::atlas_sources::texture_path(root, id, "textures");
// The limit `pack` keeps every other texture to, for a frame; and from the header first,
// so an image bigger than 64 such frames is never decoded.
let fits = |w: u32, h: u32| w <= 1024 && h <= 512;
if let Some((w, h)) = png_size(&path)
&& u64::from(w) * u64::from(h) > 1024 * 512 * 64
{
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
return None;
}
let image = match decode(&path) {
Ok(image) => image,
Err(e) => {
log::warn!("skipped custom item texture {}: {e}", path.display());
return None;
}
};
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
// armour layers are twice as wide as they are tall.
let (w, h, pixels) = match &animation {
Some(animation) => first_frame(image, Some(animation)),
None => image,
};
if !fits(w, h) {
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
return None;
}
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
@@ -490,6 +635,27 @@ impl HudAtlas {
white,
text_shader: None,
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
}
}
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
@@ -585,15 +751,66 @@ impl HudAtlas {
} else {
Default::default()
};
let blinks = if folders.contains(&"entity") {
horizon_optifine::blink::Blinks::scan(root, &random_textures)
} else {
Default::default()
};
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
e.pair_beside(root, blinks.files());
e
} else {
Default::default()
});
let mut named: HashMap<PathBuf, String> = HashMap::new();
for (file, name) in random_textures.files() {
let extra = random_textures
.files()
.chain(emissive.files())
.chain(blinks.files());
for (file, name) in extra {
named.insert(root.join(file), name.to_owned());
}
if !named.is_empty() {
// A pack's variants can't grow the shared atlas past what a GPU takes: every variant
// (those beside their texture too) counts, in name order, and those past the budget
// stay out (their mobs wear the texture itself), not the whole pack.
// Overlays and blink frames only after every variant.
let overlays: HashSet<PathBuf> = emissive
.files()
.chain(blinks.files())
.map(|(f, _)| root.join(f))
.collect();
let kept = budgeted(named.keys(), &overlays, RANDOM_TEXELS);
if kept.len() < named.len() {
log::warn!(
"{} random entity textures left out of the atlas: past its budget",
named.len() - kept.len()
);
}
// A dropped variant beside its texture takes its walked overlay and blink frames
// (and theirs) with it: nothing would draw them.
let png = format!("{}.png", emissive.suffix);
let png = png.as_str();
let orphans: HashSet<PathBuf> = named
.keys()
.filter(|p| !kept.contains(*p))
.filter_map(|p| p.to_str()?.strip_suffix(".png").map(str::to_owned))
.flat_map(|stem| {
["", "_blink", "_blink2"]
.into_iter()
.flat_map(move |frame| {
[format!("{stem}{frame}.png"), format!("{stem}{frame}{png}")]
})
})
.map(PathBuf::from)
.filter(|p| !named.contains_key(p))
.collect();
files.retain(|p| (!named.contains_key(p) || kept.contains(p)) && !orphans.contains(p));
// Those beside their texture are walked already.
let walked: HashSet<&PathBuf> = files.iter().collect();
let mut extra: Vec<PathBuf> = named
.keys()
let mut extra: Vec<PathBuf> = kept
.iter()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
@@ -682,10 +899,21 @@ impl HudAtlas {
}
images.push(image);
}
let mut cit_names = HashSet::new();
if full {
let (more, more_animated) = atlas_sprites(root, &images);
images.extend(more);
animated.extend(more_animated);
for (image, image_flags, is_animated) in cit_sprites(root, &images) {
cit_names.insert(image.0.clone());
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
if is_animated {
animated.push(image.0.clone());
}
images.push(image);
}
}
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
if let Some(edition) = images
@@ -720,7 +948,8 @@ impl HudAtlas {
}
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
@@ -815,6 +1044,8 @@ impl HudAtlas {
glint_armor,
text_shader: None,
random_textures,
emissive,
blinks,
font,
white,
})
@@ -1368,7 +1599,7 @@ impl HudBatch {
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
offset: 1.0,
}
} else {
font.glyph_styled(ch, style)
@@ -1430,12 +1661,6 @@ impl HudBatch {
object,
) in &glyphs
{
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
let shadow_color = if let Some(argb) = custom_shadow {
let text_alpha = (c >> 24) as u8 as u16;
let shadow_alpha = (argb >> 24) as u8 as u16;
@@ -1456,6 +1681,18 @@ impl HudBatch {
(0.0, *c)
};
let (px, py) = (*gx + dx, y + dx);
if let Some(object) = object {
// `PlainTextRenderable.render`: sprite glyphs use the same
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
let start = self.vertices.len();
self.draw_inline_object(atlas, object, px, py - 1.0, col);
if world && has_shadow && !is_shadow {
for v in &mut self.vertices[start..] {
v.depth = 0.03;
}
}
continue;
}
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
@@ -1533,10 +1770,9 @@ impl HudBatch {
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
tint: u32,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
@@ -2320,6 +2556,106 @@ mod tests {
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
/// OptiFine random entity variants outside `textures/` pack under the names
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
#[test]
fn random_entity_variants_pack_under_their_scan_names() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let random = root.join("assets/minecraft/optifine/random/entity/pig");
fs::create_dir_all(&random).unwrap();
write_strip(&random.join("pig2.png"), 64, 64, &[[0, 200, 0, 255]], None);
let beside = root.join("assets/minecraft/textures/entity/cat");
fs::create_dir_all(&beside).unwrap();
fs::write(beside.join("cat.properties"), "textures.1=2").unwrap();
write_strip(&beside.join("cat.png"), 64, 32, &[[1, 1, 1, 255]], None);
write_strip(&beside.join("cat2.png"), 64, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["minecraft:optifine/random/entity/pig/pig2"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 200, 0, 255]);
let rect = atlas.sprites["entity/cat/cat2"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
assert!(atlas.random_textures.get("entity/pig/pig").is_some());
fs::remove_dir_all(&root).unwrap();
}
/// A random variant's emissive overlay packs beside the variant, under its name.
#[test]
fn variant_overlays_pack_under_their_names() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-overlay-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let random = root.join("assets/minecraft/optifine/random/entity/zombie");
fs::create_dir_all(&random).unwrap();
write_strip(
&random.join("zombie3.png"),
64,
64,
&[[0, 200, 0, 255]],
None,
);
write_strip(
&random.join("zombie3_e.png"),
64,
64,
&[[200, 0, 0, 255]],
None,
);
// A variant's blink frame, with its own overlay.
write_strip(
&random.join("zombie3_blink.png"),
64,
64,
&[[0, 0, 200, 255]],
None,
);
write_strip(
&random.join("zombie3_blink_e.png"),
64,
64,
&[[9, 9, 9, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_blink_e"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [9, 9, 9, 255]);
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_e"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
assert_eq!(
atlas
.emissive
.overlay("minecraft:optifine/random/entity/zombie/zombie3"),
Some("minecraft:optifine/random/entity/zombie/zombie3_e")
);
fs::remove_dir_all(&root).unwrap();
}
/// Variants past the budget, in name order, stay out of the atlas.
#[test]
fn random_variants_stop_at_their_budget() {
let dir = std::env::temp_dir().join(format!("horizon-atlas-budget-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let files: Vec<PathBuf> = ["a2.png", "a3.png", "a4.png"]
.iter()
.map(|n| dir.join(n))
.collect();
for f in &files {
write_strip(f, 16, 16, &[[1, 2, 3, 255]], None);
}
let refs: Vec<&PathBuf> = files.iter().collect();
// 256 texels each: 600 holds two.
let kept = within_budget(&refs, 600);
assert_eq!(kept, files[..2].iter().cloned().collect());
assert_eq!(within_budget(&refs, 768).len(), 3);
// a2's overlay sorts before a3 by name, but every variant comes first.
let overlay = dir.join("a2_e.png");
write_strip(&overlay, 16, 16, &[[1, 2, 3, 255]], None);
let overlays: HashSet<PathBuf> = [overlay.clone()].into();
let all = [&files[0], &overlay, &files[1]];
let kept = budgeted(all.into_iter(), &overlays, 600);
assert_eq!(kept, [files[0].clone(), files[1].clone()].into());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn content_mod_effect_icons_pack_under_their_namespace() {
let root =
@@ -2464,6 +2800,209 @@ mod tests {
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
/// A resource root with one plain item texture and these CIT files (relative path, contents).
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let item = root.join("assets/minecraft/textures/item");
fs::create_dir_all(&item).unwrap();
write_strip(&item.join("stick.png"), 16, 16, &[[90, 60, 30, 255]], None);
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(&cit).unwrap();
for (rel, text) in files {
fs::write(cit.join(rel), text).unwrap();
}
root
}
#[test]
fn a_cit_item_rules_texture_is_packed_under_its_id() {
let root = cit_root("plain", &[("a.properties", "items=stick\ntexture=a")]);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (2, 2));
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
}
#[test]
fn a_still_cit_texture_is_packed_whole() {
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 4, 2, &[[7, 8, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (4, 2));
}
#[test]
fn an_animated_cit_texture_packs_one_frame_and_animates() {
let root = cit_root("anim", &[("a.properties", "items=stick\ntexture=a")]);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(
&cit.join("a.png"),
16,
48,
&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]],
Some(r#"{"animation": {"frametime": 2}}"#),
);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [255, 0, 0, 255]);
let animations: Vec<_> = atlas
.sprite_animations
.iter()
.filter(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.collect();
assert_eq!(animations.len(), 1);
assert_eq!(animations[0].1.frame_count(), 3);
}
#[test]
fn a_texture_only_an_armor_rule_names_is_not_packed() {
let root = cit_root(
"armor",
&[(
"a.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=a",
)],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
// The rule loads; only its kind keeps the texture out.
assert_eq!(crate::cit::load_rules(&root).len(), 1);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
}
#[test]
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
let root = cit_root(
"mixed",
&[
// Vanilla's own texture: already packed, under its usual name.
(
"v.properties",
"items=stick
texture=item/stick",
),
(
"sub.properties",
"items=bow
texture.bow_pulling_0=b",
),
(
"broken.properties",
"items=apple
texture=broken",
),
(
"wide.properties",
"items=egg
texture=wide",
),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("b.png"), 2, 2, &[[5, 6, 7, 255]], None);
fs::write(cit.join("broken.png"), b"not a png").unwrap();
// A still image past the 1024x512 limit.
write_strip(&cit.join("wide.png"), 1040, 1040, &[[1, 1, 1, 255]], None);
// Vanilla's stick is already packed when the CIT pass runs.
let stick = ("item/stick".to_owned(), 16, 16, vec![0; 16 * 16 * 4]);
let added = cit_sprites(&root, &[stick]);
let names: Vec<_> = added.iter().map(|(image, _, _)| image.0.as_str()).collect();
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
// And the atlas still builds around the broken and the too-wide texture.
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(
atlas
.sprites
.contains_key("horizon_cit:minecraft/optifine/cit/b")
);
assert!(atlas.sprites.contains_key("item/stick"));
}
#[test]
fn one_oversized_texture_leaves_the_others_in() {
// `a` sorts first and claims more than any atlas; `b` is small and must still pack.
let root = cit_root(
"oversized",
&[
("a.properties", "items=stick\ntexture=a"),
("b.properties", "items=apple\ntexture=b"),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
// Only the header is read for `a`: a PNG signature and an IHDR of 20000x20000.
let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
header.extend(20000u32.to_be_bytes());
header.extend(20000u32.to_be_bytes());
fs::write(cit.join("a.png"), header).unwrap();
write_strip(&cit.join("b.png"), 2, 2, &[[4, 5, 6, 255]], None);
let added = cit_sprites(&root, &[]);
let _ = fs::remove_dir_all(&root);
let names: Vec<_> = added.iter().map(|(i, _, _)| i.0.as_str()).collect();
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
}
#[test]
fn a_wide_grid_animation_packs_its_frame() {
// 1104 wide is past the limit for a still texture, not for a row of 16x16 frames.
let root = cit_root("grid", &[("a.properties", "items=stick\ntexture=a")]);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 1104, 16, &[[9, 9, 9, 255]], None);
let mcmeta = r#"{"animation":{"width":16,"height":16}}"#;
fs::write(cit.join("a.png.mcmeta"), mcmeta).unwrap();
let added = cit_sprites(&root, &[]);
let _ = fs::remove_dir_all(&root);
let sizes: Vec<_> = added.iter().map(|(i, _, _)| (i.1, i.2)).collect();
assert_eq!(sizes, [(16, 16)]);
}
#[test]
fn too_many_custom_item_textures_leave_them_out() {
// A 1024 atlas holds the stick and the glyph rows, not an image 1100 wide.
let image = |name: &str, side: u32| {
let pixels = vec![0; (side * side * 4) as usize];
(name.to_owned(), side, side, pixels)
};
let mut images = vec![image("item/stick", 16), image("horizon_cit:big", 1100)];
let mut animated = vec!["horizon_cit:big".to_owned()];
let extras = HashSet::from(["horizon_cit:big".to_owned()]);
assert!(HudAtlas::layout(&images, 1024).is_err());
HudAtlas::layout_leaving_out(&mut images, &mut animated, &extras, 1024).unwrap();
let names: Vec<_> = images.iter().map(|i| i.0.as_str()).collect();
assert_eq!(names, ["item/stick"]);
assert!(animated.is_empty());
// Without extras to leave out, it fails as before.
let mut alone = vec![image("item/big", 1100)];
let none = HashSet::new();
assert!(HudAtlas::layout_leaving_out(&mut alone, &mut animated, &none, 1024).is_err());
}
#[test]
fn a_root_without_cit_rules_packs_the_same_sprites() {
let root = cit_root("none", &[("a.properties", "items=stick\ntexture=a")]);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
let with: HashSet<String> = HudAtlas::build(&root)
.unwrap()
.sprites
.into_keys()
.collect();
fs::remove_dir_all(root.join("assets/minecraft/optifine")).unwrap();
let without: HashSet<String> = HudAtlas::build(&root)
.unwrap()
.sprites
.into_keys()
.collect();
let _ = fs::remove_dir_all(&root);
assert!(without.is_subset(&with));
let added: Vec<_> = with.difference(&without).collect();
assert_eq!(added, ["horizon_cit:minecraft/optifine/cit/a"]);
assert!(without.contains("item/stick"));
}
/// `count` images of `w x h`; layout reads only their sizes.
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
(0..count)
@@ -2725,6 +3264,13 @@ mod tests {
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, true);
assert_eq!(batch.vertices.len(), 24); // shadow and front, each with face and hat
assert_eq!(batch.vertices[0].pos, [11.0, 10.0]);
assert_eq!(batch.vertices[0].color, rgba(63, 63, 63, 255));
assert_eq!(batch.vertices[12].pos, [10.0, 9.0]);
assert_eq!(batch.vertices[12].color, u32::MAX);
fs::remove_dir_all(root).unwrap();
}
+1
View File
@@ -20,6 +20,7 @@ pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod cit_assets;
pub mod clouds;
pub mod collisions;
pub mod command_block;
+202 -16
View File
@@ -26,6 +26,11 @@ const MAX_SAVED: usize = 1024;
/// (`Lod::observing`).
pub const OBSERVE_AHEAD: usize = 8;
const SAMPLE_JOBS: usize = 4;
/// Sample jobs queued per sampling thread once the near terrain is in.
/// Results are taken and jobs started once a frame: with four in flight, a
/// tile taking milliseconds left the threads idle for most of a slow frame
/// (2-3 fps on a CPU renderer, with far terrain a few tiles a frame).
const QUEUED_PER_SAMPLER: usize = 32;
/// Threads that sample far tiles beside the mesher. Sampling is the cold-load
/// bottleneck (each task evaluates a shared density lattice for 256 columns),
/// so medium machines use half their cores, not a quarter.
@@ -898,8 +903,9 @@ struct Shared {
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
}
/// One background worker and at most two outstanding sample jobs. Only the
/// current view's quadtree and a bounded set of visited summaries are retained.
/// A background worker, sampling and meshing threads, and a bounded queue of
/// sample jobs (`sample_jobs`). Only the current view's quadtree and a
/// bounded set of visited summaries are retained.
pub struct Lod {
shared: Arc<Shared>,
thread: Option<thread::JoinHandle<()>>,
@@ -1193,12 +1199,17 @@ impl Lod {
if worker.stop.load(Ordering::Relaxed) {
break;
}
if sampling.is_empty() {
// Summaries and detail requests first: they
// would otherwise wait behind a whole queue of
// samples where no sampling threads take them.
let other =
jobs.iter().position(|job| !matches!(job, Job::Sample(_)));
if let Some(index) = other {
jobs.remove(index)
} else if sampling.is_empty() {
jobs.pop_front()
} else {
jobs.iter()
.position(|job| !matches!(job, Job::Sample(_)))
.and_then(|index| jobs.remove(index))
None
}
};
let Some(job) = job else { continue };
@@ -1260,7 +1271,7 @@ impl Lod {
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
small: cores <= 4,
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
sample_jobs: sampling_threads(cores) * QUEUED_PER_SAMPLER,
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
@@ -1326,14 +1337,31 @@ impl Lod {
}
(desired, sampled)
}
/// Drawn columns that take their chunk's generated detail (trees,
/// structures; `request_detail`), and how many of them have it.
/// `refined_samples` counts the detail of every drawn tile instead.
pub fn detail_progress(&self) -> (usize, usize) {
self.active
.iter()
.filter(|key| self.wants_detail(key))
.fold((0, 0), |(wanted, refined), key| {
let mask = self.detail_masks.get(key).map_or(0, detail_count);
(wanted + CELLS * CELLS, refined + mask)
})
}
pub fn refined_samples(&self) -> usize {
self.active
.iter()
.filter_map(|key| self.detail_masks.get(key))
.flat_map(|mask| mask.iter())
.map(|bits| bits.count_ones() as usize)
.map(detail_count)
.sum()
}
/// An active tile whose columns take generated chunks' detail.
fn wants_detail(&self, key: &TileKey) -> bool {
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
}
pub fn removed(&mut self) -> Vec<TileKey> {
std::mem::take(&mut self.removed)
}
@@ -1611,6 +1639,11 @@ impl Lod {
pub fn observing(&self) -> usize {
self.shared.observed.lock().unwrap().len()
}
/// Whether tiles new to the plan are being sampled: far terrain on its way,
/// meshed as soon as it is.
pub fn sampling(&self) -> bool {
!self.pending.is_empty()
}
/// Keeps a visited chunk's summary, unless it is unchanged.
fn finish_observed(&mut self, key: (i32, i32), columns: ReducedColumns) {
if self
@@ -1733,9 +1766,43 @@ impl Lod {
// neighbours. Coarser tiles do not read visited columns (stride > 16),
// so re-meshing every tile touching the chunk was pure waste.
}
/// Whether the near terrain is still loading: on small machines far
/// terrain then holds back (`HELD_SAMPLE_STEP`).
/// Whether the near terrain is still loading: far terrain then queues
/// `SAMPLE_JOBS` at most, and on small machines holds back
/// (`HELD_SAMPLE_STEP`).
pub fn set_near_loading(&mut self, loading: bool) {
if loading && !self.near_loading {
// The queue filled while nothing loaded nearby; the near chunks
// must not wait behind it. Larger machines keep its first
// `SAMPLE_JOBS`, small ones none.
let mut keep = if self.small { 0 } else { SAMPLE_JOBS };
let dropped = drop_queued_samples(&self.shared, &mut self.pending, |_| {
let kept = keep > 0;
keep = keep.saturating_sub(1);
kept
});
// Back in line where `wanted_tiles` put them: the near terrain can
// start and stop loading every few frames, and rebuilding the whole
// queue each time cost more than the samples it held back.
let camera = self.plan.camera;
self.wanted.extend(dropped.into_iter().map(|key| {
let class = if self.plan.split.contains(&key) {
BACKDROP
} else if key.reach(camera) < self.plan.first {
SEAM
} else {
REST
};
std::cmp::Reverse(Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
})
}));
// A sample may still be running: the held pace starts from now.
self.held_sample_at = std::time::Instant::now();
}
self.near_loading = loading;
let held = self.held();
if self.shared.mesh_held.swap(held, Ordering::Release) && !held {
@@ -1764,10 +1831,7 @@ impl Lod {
.active
.iter()
.filter(|key| {
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
self.wants_detail(key) && self.detail_masks.get(key) != Some(&[u64::MAX; 4])
})
.copied()
.collect();
@@ -1876,6 +1940,11 @@ impl Lod {
self.reduced_detail,
);
self.ready.retain(|key, _| self.plan.nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
self.detail_masks
.retain(|key, _| self.plan.nodes.contains(key));
// Complete resident columns need no duplicate noise work.
@@ -2245,7 +2314,8 @@ impl Lod {
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
)
} else {
// The extra sampling threads wait until the near terrain is in.
// `SAMPLE_JOBS` while the near terrain loads, the full queue
// (`QUEUED_PER_SAMPLER` a thread) once it is in.
let slots = if self.near_loading {
SAMPLE_JOBS
} else {
@@ -2467,6 +2537,28 @@ impl Drop for Lod {
}
}
}
/// The columns of a tile's `detail_masks` entry that have their detail.
fn detail_count(mask: &[u64; 4]) -> usize {
mask.iter().map(|bits| bits.count_ones() as usize).sum()
}
/// Removes the queued sample jobs `keep` rejects (in queue order, best
/// first) and frees their slots; the tiles removed.
fn drop_queued_samples(
shared: &Shared,
pending: &mut FxHashSet<TileKey>,
mut keep: impl FnMut(&TileKey) -> bool,
) -> Vec<TileKey> {
let mut dropped = Vec::new();
shared.jobs.lock().unwrap().retain(|job| match job {
Job::Sample(key) if !keep(key) => {
pending.remove(key);
dropped.push(*key);
false
}
_ => true,
});
dropped
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual detail-request
/// budget and waits for the replacement mesh before changing cutout.
@@ -3595,6 +3687,100 @@ mod tests {
assert_eq!(lod.shared.mesh_jobs.lock().unwrap().len(), 1);
}
#[test]
fn queued_samples_follow_the_view_and_make_way_for_the_near_terrain() {
let mut lod = Lod::scheduler_for_test();
lod.small = true;
lod.sample_jobs = 16;
let queued = |lod: &Lod| -> Vec<TileKey> {
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter_map(|job| match job {
Job::Sample(key) => Some(*key),
_ => None,
})
.collect()
};
let observing = |lod: &Lod| {
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter().any(|job| matches!(job, Job::Observe((5, 5))))
};
let pending = |lod: &Lod| -> Vec<TileKey> {
let mut keys: Vec<_> = lod.pending.iter().copied().collect();
keys.sort();
keys
};
let sorted = |mut keys: Vec<TileKey>| {
keys.sort();
keys
};
let none = || Some(FxHashSet::default());
lod.update([8., 64., 8.], 2, 64, none());
let before = queued(&lod);
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
lod.shared
.jobs
.lock()
.unwrap()
.push_back(Job::Observe((5, 5)));
// A chunk's move: the tiles still planned keep their place in line.
lod.update([24., 64., 8.], 2, 64, none());
let moved = queued(&lod);
let kept: Vec<_> = before
.iter()
.copied()
.filter(|key| lod.plan.nodes.contains(key))
.collect();
assert!(!kept.is_empty());
assert_eq!(moved[..kept.len()], kept[..]);
assert_eq!(moved.len(), 16);
assert!(observing(&lod), "only sample jobs are dropped");
// A teleport: none of the old view's tiles is sampled any more.
lod.update([100_000., 64., 100_000.], 2, 64, none());
let after = queued(&lod);
assert_eq!(after.len(), 16);
assert!(after.iter().all(|key| !moved.contains(key)));
assert!(after.iter().all(|key| lod.plan.nodes.contains(key)));
assert_eq!(
pending(&lod),
sorted(after.clone()),
"dropped jobs free their slots"
);
// New chunks load nearby: the queue gives way at once, not after it
// drained, and the dropped tiles come back, best first, once they are in.
lod.set_near_loading(true);
assert!(queued(&lod).is_empty());
assert!(lod.pending.is_empty());
assert!(observing(&lod));
lod.set_near_loading(false);
lod.update([100_000., 64., 100_000.], 2, 64, none());
assert_eq!(queued(&lod), after);
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
lod.small = false;
lod.set_near_loading(true);
assert_eq!(queued(&lod), after[..SAMPLE_JOBS]);
assert_eq!(pending(&lod), sorted(after[..SAMPLE_JOBS].to_vec()));
}
#[test]
fn detail_progress_counts_the_columns_that_take_generated_detail() {
let mut lod = Lod::scheduler_for_test();
let tile = |x, level| TileKey { x, z: 0, level };
let (fine, covered, coarse, split) = (tile(0, 0), tile(1, 0), tile(1, 3), tile(1, 2));
lod.active = [fine, covered, coarse, split].into_iter().collect();
lod.covered.insert((1, 0));
lod.plan.split.insert(split);
lod.detail_masks.insert(fine, [u64::MAX, 1, 0, 0]);
lod.detail_masks.insert(coarse, [u64::MAX; 4]);
// Only the fine tile: the covered one is the near chunk's, the split
// one stands in for finer tiles and the coarse one is past the detail.
assert_eq!(lod.detail_progress(), (256, 65));
}
/// Drives the scheduler by hand: every sample the new incremental path
/// produces must select exactly the tiles `Plan::select` selects, after
/// every single sample, and across camera moves and distance changes.
@@ -160,6 +160,11 @@ impl MenuBlur {
})
.collect();
}
/// Give the targets' video memory back: no frame blurs until the next
/// screen that does, which makes them again.
pub fn release(&mut self) {
self.targets.clear();
}
/// Blur `frame` (the swapchain texture) in place through `target`, its
/// non-sRGB view, by `radius` pixels (`menuBackgroundBlurriness`).
#[allow(clippy::too_many_arguments)]
@@ -238,6 +243,90 @@ impl MenuBlur {
#[cfg(test)]
mod tests {
use super::*;
/// A frame of hard stripes, blurred, read back.
fn blurred(
device: &wgpu::Device,
queue: &wgpu::Queue,
blur: &mut MenuBlur,
size: [u32; 2],
) -> Vec<u8> {
let format = wgpu::TextureFormat::Rgba8Unorm;
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
};
let stripes: Vec<u8> = (0..size[0] * size[1])
.flat_map(|i| if i % 4 < 2 { [255; 4] } else { [0, 0, 0, 255] })
.collect();
let frame = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: None,
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&stripes,
);
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(size[0] * size[1] * 4),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
let view = frame.create_view(&Default::default());
blur.apply(device, queue, &mut encoder, &frame, &view, size, 2.0, None);
encoder.copy_texture_to_buffer(
frame.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &out,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
},
extent,
);
queue.submit([encoder.finish()]);
out.slice(..).map_async(wgpu::MapMode::Read, |_| ());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
out.slice(..).get_mapped_range().unwrap().to_vec()
}
/// Released between two screens, the blur makes its targets again and
/// blurs the same.
#[test]
fn blurs_the_same_after_its_targets_were_released() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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 mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
assert!(first.chunks(4).any(|p| p[0] != 0 && p[0] != 255));
blur.release();
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+434 -51
View File
@@ -7,6 +7,7 @@ mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
@@ -15,15 +16,17 @@ use crate::{
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
pub use sides::Sides;
pub use sides::{Part, Sides, group_parts};
use std::sync::Arc;
/// An entity, item and effect vertex (the entity pipelines), with a face
@@ -472,6 +475,9 @@ struct Info {
overlay: bool,
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
leaves: bool,
/// A glass pane: connected-texture tiles take the cube's UVs on it (OptiFine's pane models
/// drop vanilla's mirrored faces).
pane: bool,
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
/// and a full collision shape. The only block leaf culling treats as solid.
opaque_cube: bool,
@@ -652,6 +658,7 @@ impl MeshTables {
legacy_solid: s.legacy_solid,
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
leaves: s.has_class("LeavesBlock"),
pane: s.has_class("IronBarsBlock") && name.ends_with("glass_pane"),
opaque_cube: leaf_culling::opaque_cube(
s.render_shape,
s.can_occlude,
@@ -1017,6 +1024,32 @@ impl<'a> Tints<'a> {
layers: Default::default(),
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
&self.biomes.get(id).name
}
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
/// while it holds a layer.
fn biome_id(
zoom: &BiomeZoom,
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz, base_y]: [i32; 3],
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
plains
}
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
let r = self.radius;
@@ -1032,27 +1065,21 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none).
let q = self
.zoom
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
self.plains
};
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1229,11 +1256,34 @@ impl Mesher<'_> {
)
.unwrap_or(true);
}
let connected = &self.render.connected;
for model in &models {
for face in &model.faces {
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
continue;
}
// A pane's top and bottom strips between stacked panes, with
// connected textures.
if state.pane
&& !connected.is_empty()
&& let Some(dy) = match face.quad.facing {
ModelDirection::Up => Some(1),
ModelDirection::Down => Some(-1),
_ => None,
}
{
let p = &face.quad.positions;
// Only on the block's top or bottom plane: a pack's own pane
// model may have ledges between.
let plane = if dy > 0 { 1.0 } else { 0.0 };
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
}
self.face(
pad,
&mut tints,
@@ -1242,7 +1292,8 @@ impl Mesher<'_> {
origin,
face,
ao,
state,
(id, state),
world,
interior_leaf,
);
}
@@ -1281,7 +1332,8 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
state: &Info,
(id, state): (u32, &Info),
world: BlockPos,
interior_leaf: bool,
) {
let q = &face.quad;
@@ -1309,50 +1361,381 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
// drawn over it (a few per face at most; more are dropped).
let connected = &self.render.connected;
let mut overlays = [None; 8];
let mut count = 0;
let pick = if connected.is_empty() {
Pick::Keep
} else {
let tints = &*tints;
connected.pick(
id,
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[world.x, world.y, world.z],
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|overlay| {
if let Some(slot) = overlays.get_mut(count) {
*slot = Some(overlay);
count += 1;
}
},
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
uv[i],
)
})
};
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
let own = TileLayer::Layer {
layer: face.layer as u16,
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
}
}
}
}
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
// face it must be in the translucent pass too, or the face would draw over it.
// Most faces have none: nothing below runs for them.
if count == 0 {
return;
}
let face_translucent = match pick {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
};
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations).
// Over a `ctm_compact` face, the same pieces: the same depths and sort centres.
let cuts = match pick {
Pick::Quarters(_) => cuts(&corners),
_ => Cuts([0.0, 1.0, 1.0, 1.0], 2),
};
for overlay in overlays[..count].iter().flatten().copied() {
let tint = overlay.tint.and_then(|(index, block)| {
let info = block
.and_then(|b| self.tables.info.get(b as usize))
.unwrap_or(state);
Self::tint(info, index, tints, pos)
});
let tile = TileLayer::Layer {
layer: overlay.layer,
translucent: overlay.translucent || face_translucent,
opaque: false,
};
let whole = vertices(tint, standard());
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&whole, s, t).0);
self.quad_as(out, false, tile, piece, false);
}
}
}
}
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
/// alpha cutout whatever the sprite).
fn quad(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
tile: TileLayer,
v: [TerrainVertex; 4],
) {
self.quad_as(out, solid_leaves, tile, v, true);
}
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
fn translucent(&self, tile: TileLayer) -> bool {
match tile {
TileLayer::Layer {
layer, translucent, ..
} => {
translucent
|| self
.translucent_layers
.get(usize::from(layer))
.copied()
.unwrap_or(false)
}
TileLayer::Skip | TileLayer::Default => false,
}
}
/// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only
/// when `by_sprite`: an overlay's pass is its rule's.
fn quad_as(
&self,
out: &mut SectionMesh,
solid_leaves: bool,
tile: TileLayer,
mut v: [TerrainVertex; 4],
by_sprite: bool,
) {
let TileLayer::Layer {
layer,
translucent,
opaque,
} = tile
else {
return;
};
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
let translucent = if by_sprite {
self.translucent(tile)
} else {
translucent
};
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
// skips its clear top row). All four vertices share the layer.
let layer = if opaque && !translucent || solid_leaves {
layer | OPAQUE_LAYER
} else {
layer
};
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
for vertex in &mut v {
vertex.layer = layer;
}
target.extend(v);
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
/// of `.0`.
struct Cuts([f32; 4], usize);
impl std::ops::Deref for Cuts {
type Target = [f32];
fn deref(&self) -> &[f32] {
&self.0[..self.1]
}
}
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
/// slab's side).
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
let (a, b) = (c[0].1, c[to].1);
// The UV the edge runs along.
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
let at = (0.5 - a[k]) / (b[k] - a[k]);
(at > 0.0 && at < 1.0).then_some(at)
}
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
/// split face shades exactly like the whole one; the others lie on one side of it.
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
let mut out = Cuts([0.0; 4], 1);
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
if s > t {
std::mem::swap(&mut s, &mut t);
}
for at in [s, t].into_iter().flatten().chain([1.0]) {
if out.0[out.1 - 1] < at {
out.0[out.1] = at;
out.1 += 1;
}
}
out
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
use horizon_assets::model::Direction as D;
match facing {
D::Down => [x, 1.0 - z],
D::Up => [x, z],
D::North => [1.0 - x, 1.0 - y],
D::South => [x, 1.0 - y],
D::West => [z, 1.0 - y],
D::East => [1.0 - z, 1.0 - y],
}
}
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
/// quad lies in, by its UV centre (v grows downward).
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
}
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
/// like an unsplit one.
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
let w = if s >= t {
[1.0 - s, s - t, t, 0.0]
} else {
[1.0 - t, 0.0, s, t - s]
};
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
};
// Packed bytes (colour, light) blend one by one.
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
(0..count)
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
.sum::<u32>()
};
let color = bytes(|v| v.color, 4);
let light = bytes(|v| u32::from(v.light), 2) as u16;
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
(TerrainVertex::new(pos, uv, 0, color, light), uv)
}
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
}
#[cfg(test)]
mod quarter_tests {
use super::*;
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
}
#[test]
fn quarters_blend_the_corners_by_triangle() {
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
// middle, where a bilinear blend would put a quarter and the other diagonal none.
let c = [
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
];
let (centre, uv) = bilinear(&c, 0.5, 0.5);
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
assert_eq!(uv, [0.5, 0.5]);
assert_eq!(centre.color, 0xFF00_0000 | 100);
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
}
#[test]
fn compact_faces_cut_where_the_sprite_does() {
// A full face: cut in the middle both ways.
let full = [
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
];
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
// directions are cut there and at the middle of u.
let mut path = full;
path[0].1 = [0.0, 1.0 / 16.0];
path[1].1 = [1.0, 1.0 / 16.0];
assert_eq!(
*cuts(&path),
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
);
// A slab's lower side (v 0.5..1) crosses the middle only along u.
let mut slab = full;
slab[0].1 = [0.0, 0.5];
slab[1].1 = [1.0, 0.5];
assert_eq!(crossing(&slab, 3), None);
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
let piece = |s0: f32, t0: f32| {
[
(s0, t0),
(s0 + 0.5, t0),
(s0 + 0.5, t0 + 0.5),
(s0, t0 + 0.5),
]
.map(|(s, t)| bilinear(&full, s, t))
};
assert_eq!(
[
piece(0.0, 0.0),
piece(0.5, 0.0),
piece(0.0, 0.5),
piece(0.5, 0.5)
]
.map(|p| quarter_of(&p)),
[0, 1, 2, 3]
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
// A top face: the north-west corner is the sprite's top-left, so a tile's top edge is
// north whatever the model's turn.
assert_eq!(standard_uv(D::Up, [0.0, 1.0, 0.0]), [0.0, 0.0]);
assert_eq!(standard_uv(D::Up, [1.0, 1.0, 1.0]), [1.0, 1.0]);
// A north face seen from the north: its top-left is the east top corner.
assert_eq!(standard_uv(D::North, [1.0, 1.0, 0.0]), [0.0, 0.0]);
assert_eq!(standard_uv(D::East, [1.0, 0.0, 1.0]), [0.0, 1.0]);
assert_eq!(standard_uv(D::Down, [0.0, 0.0, 1.0]), [0.0, 0.0]);
}
}
@@ -322,6 +322,7 @@ mod tests {
legacy_solid: false,
overlay: leaves,
leaves,
pane: false,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
+220 -39
View File
@@ -16,6 +16,15 @@
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
//!
//! The opaque quads (`OPAQUE_LAYER`) and the cutout ones of each bucket are
//! apart, so that each of Metal's two solid passes draws only its own quads
//! (`Part`). Where the renderer draws them in two passes (`group_parts`), all
//! the opaque buckets come first, then all the cutout ones, so each part is
//! one or two runs; elsewhere each bucket holds its opaque quads, then its
//! cutout ones, so one pass over all of them draws the runs it drew before.
use std::sync::atomic::{AtomicBool, Ordering};
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
@@ -44,12 +53,37 @@ enum Bucket {
Other,
}
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
/// whether it can face the eye.
/// Whether meshes sorted from now on put every bucket's opaque quads before
/// any cutout ones: set by the renderer when it draws them in two passes.
static GROUPED: AtomicBool = AtomicBool::new(false);
/// Sorts later meshes for two solid passes (`true`) or one.
pub fn group_parts(on: bool) {
GROUPED.store(on, Ordering::Relaxed);
}
/// The bucket and whether it holds cutout quads of the `n`th part of a
/// mesh, in mesh order.
fn slot(grouped: bool, n: usize) -> (usize, bool) {
if grouped {
(n % 7, n >= 7)
} else {
(n / 2, n % 2 == 1)
}
}
/// Where each part of each bucket of a mesh's solid quads ends, and the
/// planes that tell whether a bucket can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Sides {
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
ends: [u32; 7],
/// The solid quads up to the end of each part of each bucket, in mesh
/// order (`slot`).
ends: [u32; 14],
/// Whether all the opaque parts come first (`group_parts`).
grouped: bool,
/// Whether opaque and cutout quads are apart; else either part is every
/// quad.
split: bool,
/// Per bucket facing a positive direction, its lowest plane; facing a
/// negative one, its highest (unused for the other bucket).
planes: [f32; 7],
@@ -59,43 +93,62 @@ impl Sides {
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
/// that were not sorted.
pub fn unsorted(quads: u32) -> Self {
let mut ends = [0; 7];
ends[OTHER..].fill(quads);
let mut ends = [0; 14];
ends[OTHER * 2..].fill(quads);
Self {
ends,
grouped: false,
split: false,
planes: [0.0; 7],
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[6]
self.ends[13]
}
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
self.visible_from(eye, eye[1], draw);
self.visible_from(eye, eye[1], Part::All, draw);
}
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
/// its water mirror's eye below the plane, which differ only in y.
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
/// its water mirror's eye below the plane, which differ only in y. Only the quads of
/// `part`, but every quad of a mesh whose parts are not split.
pub fn visible_from(&self, eye: [f32; 3], low: f32, part: Part, draw: impl FnMut(u32, u32)) {
let seen = |i: usize| match BUCKETS[i] {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
self.runs(seen, part, draw);
}
/// The runs of `part`'s quads facing every way.
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
self.runs(|_| true, part, draw);
}
/// Calls `draw` for each run of `part`'s quads in the buckets `seen` keeps.
fn runs(&self, seen: impl Fn(usize) -> bool, part: Part, mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = self.ends[i];
for (n, &end) in self.ends.iter().enumerate() {
if end == start {
continue;
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
let (bucket, cutout) = slot(self.grouped, n);
let wanted = match part {
Part::All => true,
_ if !self.split => true,
Part::Opaque => !cutout,
Part::Cutout => cutout,
};
match (seen, run) {
match (wanted && seen(bucket), run) {
(true, None) => run = Some(start),
(false, Some(first)) => {
draw(first, start - first);
@@ -111,6 +164,15 @@ impl Sides {
}
}
/// The quads `Sides::visible_from` draws: all of them, or those of one of
/// Metal's two solid passes (terrain.wgsl's `opaque_quad`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
All,
Opaque,
Cutout,
}
/// The bucket of a quad: the axis plane it lies in and the side its
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
@@ -139,18 +201,34 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
// Each quad's part, by its place in mesh order (`slot`).
let position = |b: usize, cutout: bool| match grouped {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
let b = usize::from(b);
for (quad, &part) in quads.iter().zip(&slots) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
let p = quad[0].pos[axis];
planes[b] = match (counts[b], positive) {
@@ -161,26 +239,26 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
}
counts[b] += 1;
}
let mut ends = [0u32; 7];
let mut slot_ends = [0u32; 14];
let mut total = 0;
for (end, count) in ends.iter_mut().zip(counts) {
for (end, count) in slot_ends.iter_mut().zip(slot_counts) {
total += count;
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
let slot = &mut next[usize::from(b)];
sorted[*slot as usize] = *quad;
*slot += 1;
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
@@ -196,7 +274,12 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
Sides {
ends: slot_ends,
grouped,
split: true,
planes,
}
}
#[cfg(test)]
@@ -225,6 +308,12 @@ mod tests {
.collect()
}
/// Where each bucket ends, in a mesh sorted for one pass.
fn bucket_ends(sides: &Sides) -> [u32; 7] {
assert!(!sides.grouped);
std::array::from_fn(|b| sides.ends[b * 2 + 1])
}
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
sides.visible(eye, |first, count| out.push((first, count)));
@@ -261,7 +350,7 @@ mod tests {
v.layer = (i / 4) as u16;
}
let sides = sort(&mut solid);
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
assert_eq!(bucket_ends(&sides), [1, 2, 2, 3, 4, 5, 6]);
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
// +X, +Z, +Y, -Z, -X, -Y.
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
@@ -348,7 +437,7 @@ mod tests {
let sides = sort(&mut solid);
let both = |eye: [f32; 3], low: f32| {
let mut out = Vec::new();
sides.visible_from(eye, low, |first, count| out.push((first, count)));
sides.visible_from(eye, low, Part::All, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
@@ -360,6 +449,98 @@ mod tests {
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
}
/// Opaque and cutout quads of each bucket split: each pass's runs hold
/// exactly its own quads facing the eye, and all of them together the
/// same runs as one pass.
#[test]
fn each_part_draws_its_own_quads_facing_the_eye() {
// Two blocks: one opaque, one cutout, then an opaque one again, so
// every bucket holds both kinds out of order.
let mut solid = Vec::new();
let mut id = 0u16;
for (origin, opaque) in [
([0.0, 0.0, 0.0], true),
([4.0, 2.0, 6.0], false),
([9.0, 5.0, 3.0], true),
] {
for q in cube(origin, 0).chunks(4) {
let layer = id | if opaque { OPAQUE_LAYER } else { 0 };
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
id += 1;
}
}
let unsorted = solid;
for grouped in [false, true] {
let mut solid = unsorted.clone();
let sides = sort_parts(&mut solid, grouped);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
for eye in [[20.0, 30.0, -10.0], [-5.0, 1.0, 4.0], [5.0, 3.0, 7.0]] {
let quads = |part| {
let mut out = Vec::new();
sides.visible_from(eye, eye[1], part, |first, count| {
out.extend(
layers[first as usize..(first + count) as usize]
.iter()
.copied(),
)
});
out.sort_unstable();
out
};
let mut facing = front_facing(&solid, eye);
facing.sort_unstable();
let opaque = quads(Part::Opaque);
let cutout = quads(Part::Cutout);
assert!(opaque.iter().all(|l| l & OPAQUE_LAYER != 0), "{eye:?}");
assert!(cutout.iter().all(|l| l & OPAQUE_LAYER == 0), "{eye:?}");
for l in &facing {
let mine = if l & OPAQUE_LAYER != 0 {
&opaque
} else {
&cutout
};
assert!(
mine.contains(l),
"{eye:?}: quad {l} facing the eye left out"
);
}
let mut both = [opaque, cutout].concat();
both.sort_unstable();
assert_eq!(both, quads(Part::All), "{eye:?}");
}
// Every way: each part once, whole.
let mut every = Vec::new();
sides.every(Part::Opaque, |first, count| every.push((first, count)));
sides.every(Part::Cutout, |first, count| every.push((first, count)));
let total: u32 = every.iter().map(|r| r.1).sum();
assert_eq!(total, sides.quads());
// Two passes: from above a corner, each part is one or two runs.
if grouped {
let mut count = 0;
sides.visible_from([20.0, 30.0, -10.0], 30.0, Part::Opaque, |_, _| count += 1);
assert_eq!(count, 2);
}
}
}
/// A mesh that was not sorted has no parts: each pass draws all of it
/// (the shader leaves out the other pass's quads).
#[test]
fn unsorted_meshes_draw_everything_in_each_part() {
let sides = Sides::unsorted(9);
for part in [Part::Opaque, Part::Cutout] {
let mut out = Vec::new();
sides.every(part, |first, count| out.push((first, count)));
assert_eq!(out, [(0, 9)]);
}
}
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
@@ -385,7 +566,7 @@ mod tests {
.collect();
let before = solid.clone();
let sides = sort(&mut solid);
assert_eq!(sides.ends, [3; 7]);
assert_eq!(bucket_ends(&sides), [3; 7]);
assert_eq!(
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
before.iter().map(|v| v.layer).collect::<Vec<_>>()
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
pane: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
+45 -1
View File
@@ -10,6 +10,29 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
use std::path::Path;
/// The standalone models native mods asked for (`native_standalone_models`).
static STANDALONE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
/// Sets the models no blockstate names that every resource load bakes
/// beside the block models ([`StateModels::standalone`]): once, at start,
/// as vanilla's `ModelEvent.RegisterStandalone` is posted once per model
/// discovery. Names are sorted and deduplicated; one without a namespace
/// (`mymod:block/shaft_half`) is dropped. False when already set.
pub fn register_standalone(mut names: Vec<String>) -> bool {
names.retain(|n| {
n.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty())
});
names.sort();
names.dedup();
STANDALONE.set(names).is_ok()
}
/// What [`register_standalone`] set; none before.
pub fn standalone() -> &'static [String] {
STANDALONE.get().map_or(&[], Vec::as_slice)
}
type Placement = (&'static str, &'static [(&'static str, &'static str)]);
const GALLERY: [Placement; 16] = [
(
@@ -76,8 +99,9 @@ pub(crate) fn prepare_with_models(
.filter(|name| registry.default_state(name).is_some())
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
.collect();
let catalog =
let mut catalog =
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
catalog.bake_standalone(root, sprites, models, standalone())?;
let mut placements = Vec::new();
let mut states = Vec::new();
for (index, (name, properties)) in GALLERY.iter().enumerate() {
@@ -149,3 +173,23 @@ pub fn load_visibility(
.collect::<std::result::Result<_, &str>>()?;
Ok(FaceVisibility::new(&occlusion, &definitions)?)
}
#[cfg(test)]
mod tests {
#[test]
fn standalone_names_are_namespaced_sorted_and_set_once() {
assert!(super::standalone().is_empty());
let names = [
"lanef:block/b",
"nonamespace",
":x",
"lanef:",
"lanef:block/a",
"lanef:block/b",
];
assert!(super::register_standalone(names.map(str::to_owned).into()));
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
assert!(!super::register_standalone(vec!["other:block/c".into()]));
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
}
}
@@ -52,10 +52,55 @@ pub struct StateModels {
states: Vec<Option<(usize, SelectedModels)>>,
/// Every transformed model once, with the variant it was baked from.
baked: Vec<(Variant, BakedModel)>,
/// Standalone models by id: their index in `baked`.
standalone: BTreeMap<String, u32>,
}
fn key(variant: &Variant) -> VariantKey {
(variant.model.clone(), variant.rotations, variant.uv_lock)
}
/// Bakes model `name` of `library`, turned and UV-locked as a variant says.
fn bake_variant(
root: &Path,
sprites: &mut Sprites,
library: &horizon_assets::resolve::ModelLibrary,
name: &str,
turns: [u8; 3],
lock: bool,
) -> Result<BakedModel> {
bake_resolved(root, sprites, &library.resolve(name)?, turns, lock)
}
/// Bakes a resolved `model`, turned and UV-locked as a variant says.
fn bake_resolved(
root: &Path,
sprites: &mut Sprites,
model: &horizon_assets::resolve::ResolvedCuboidModel,
turns: [u8; 3],
lock: bool,
) -> Result<BakedModel> {
let particle = model
.material("particle")?
.map(|material| sprites.insert(root, &material.sprite))
.transpose()?;
let faces: Vec<BakedFace> = bake_block_model(model, BlockModelTransform::new(turns, lock))?
.into_iter()
.map(|quad| {
let layer = sprites.insert(root, &quad.material.sprite)?;
Ok(BakedFace {
translucent: quad.material.force_translucent || sprites.translucent[layer as usize],
opaque: sprites.region_opaque(layer, &quad.uv),
quad,
layer,
})
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
Ok(BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
})
}
impl StateModels {
pub fn load(
root: &Path,
@@ -219,41 +264,30 @@ impl StateModels {
models.block_library_or_missing(root, names)?
}
};
let resolved: Vec<_> = variants
.iter()
.map(|(name, ..)| library.resolve(name))
.collect();
// Every texture the models name, decoded side by side before the bake adds them one
// by one in its own order.
sprites.prefetch(
root,
resolved
.iter()
.flatten()
.flat_map(|model| model.textures.values())
.map(|material| material.sprite.as_str()),
);
let mut baked = Vec::with_capacity(variants.len());
let mut index = BTreeMap::new();
for (name, turns, lock) in variants {
let model = library.resolve(&name)?;
let particle = model
.material("particle")?
.map(|material| sprites.insert(root, &material.sprite))
.transpose()?;
let faces: Vec<BakedFace> =
bake_block_model(&model, BlockModelTransform::new(turns, lock))?
.into_iter()
.map(|quad| {
let layer = sprites.insert(root, &quad.material.sprite)?;
Ok(BakedFace {
translucent: quad.material.force_translucent
|| sprites.translucent[layer as usize],
opaque: sprites.region_opaque(layer, &quad.uv),
quad,
layer,
})
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
let model = bake_resolved(root, sprites, &model?, turns, lock)?;
index.insert((name.clone(), turns, lock), baked.len() as u32);
let variant = Variant {
model: name,
rotations: turns,
uv_lock: lock,
};
let model = BakedModel {
ambient_occlusion: model.ambient_occlusion,
particle,
faces,
cube,
};
baked.push((variant, model));
}
// Every variant of every definition was baked above.
@@ -262,8 +296,58 @@ impl StateModels {
choices,
states,
baked,
standalone: BTreeMap::new(),
})
}
/// Bakes `names`, models no blockstate needs to name (vanilla's
/// standalone models, NeoForge's `ModelEvent.RegisterStandalone`), as
/// they are, unturned, for [`Self::standalone`]. One a block state
/// already bakes unturned is shared. A model that doesn't read (missing,
/// or a parent missing) is logged and left out, as a missing blockstate
/// is; the others still bake.
pub(crate) fn bake_standalone(
&mut self,
root: &Path,
sprites: &mut Sprites,
models: &crate::model_library::ParsedModels,
names: &[String],
) -> Result<()> {
for name in names {
if self.standalone.contains_key(name) {
continue;
}
if let Some(i) = self
.baked
.iter()
.position(|(v, _)| v.model == *name && v.rotations == [0; 3] && !v.uv_lock)
{
self.standalone.insert(name.clone(), i as u32);
continue;
}
let model = models
.block_library(root, vec![name.clone()])
.and_then(|library| bake_variant(root, sprites, &library, name, [0; 3], false));
match model {
Ok(model) => {
self.standalone
.insert(name.clone(), self.baked.len() as u32);
let variant = Variant {
model: name.clone(),
rotations: [0; 3],
uv_lock: false,
};
self.baked.push((variant, model));
}
Err(e) => log::warn!("Horizon: standalone model {name} not loaded: {e}"),
}
}
Ok(())
}
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
let &i = self.standalone.get(name)?;
Some(&self.baked[i as usize].1)
}
pub fn collect(
&self,
state: BlockStateId,
@@ -328,3 +412,103 @@ pub fn descriptor(name: &str, overrides: &[(&str, &str)]) -> BlockState {
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_library::ParsedModels;
struct Fixture(std::path::PathBuf);
impl Fixture {
fn write(&self, name: &str, json: &str) {
let (namespace, path) = name.split_once(':').unwrap();
let path = self
.0
.join(format!("assets/{namespace}/models/{path}.json"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, json).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn standalone_models_bake_once_and_skip_what_does_not_read() -> Result<()> {
let fixture = Fixture(
std::env::temp_dir().join(format!("horizon-standalone-{}", std::process::id())),
);
// A half shaft: one element, two faces; a child with its parent's elements.
fixture.write(
"lanef:block/shaft_half",
r##"{"textures":{"side":"lanef:block/shaft"},"elements":[{"from":[6,6,0],"to":[10,10,8],"faces":{"north":{"texture":"#side"},"up":{"texture":"#side"}}}]}"##,
);
fixture.write(
"lanef:block/cog",
r#"{"parent":"lanef:block/shaft_half","textures":{"side":"lanef:block/cog"}}"#,
);
fixture.write("lanef:block/orphan", r#"{"parent":"lanef:block/absent"}"#);
let mut models = StateModels::default();
let mut sprites = Sprites::default();
let names: Vec<String> = [
"lanef:block/shaft_half",
"lanef:block/cog",
"lanef:block/orphan",
"lanef:block/absent",
"lanef:block/shaft_half",
]
.map(str::to_owned)
.into();
let parsed = ParsedModels::load(&fixture.0);
models.bake_standalone(&fixture.0, &mut sprites, &parsed, &names)?;
let shaft = models.standalone("lanef:block/shaft_half").expect("baked");
assert_eq!(shaft.faces.len(), 2);
let cog = models.standalone("lanef:block/cog").expect("baked");
assert_eq!(cog.faces.len(), 2);
assert_ne!(cog.faces[0].layer, shaft.faces[0].layer, "its own texture");
assert!(models.standalone("lanef:block/orphan").is_none());
assert!(models.standalone("lanef:block/absent").is_none());
assert!(models.standalone("lanef:block/unnamed").is_none());
// Named twice, baked once.
assert_eq!(models.baked.len(), 2);
// Without a parsed library, the files are read directly.
let mut direct = StateModels::default();
direct.bake_standalone(&fixture.0, &mut sprites, &ParsedModels::default(), &names)?;
assert_eq!(
direct.standalone("lanef:block/cog").map(|m| m.faces.len()),
Some(2)
);
Ok(())
}
#[test]
fn a_standalone_model_a_state_bakes_unturned_is_shared() -> Result<()> {
let mut models = StateModels::default();
let variant = |rotations| Variant {
model: "lanef:block/shared".into(),
rotations,
uv_lock: false,
};
let empty = || BakedModel {
ambient_occlusion: true,
particle: None,
faces: Vec::new(),
cube: None,
};
models.baked.push((variant([0, 90, 0]), empty()));
models.baked.push((variant([0; 3]), empty()));
let names = vec!["lanef:block/shared".to_owned()];
let root = std::env::temp_dir().join("horizon-standalone-none");
models.bake_standalone(
&root,
&mut Sprites::default(),
&ParsedModels::default(),
&names,
)?;
assert_eq!(models.baked.len(), 2);
assert_eq!(models.standalone.get("lanef:block/shared"), Some(&1));
Ok(())
}
}
@@ -1,8 +1,13 @@
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
pub const MAX: usize = 32;
/// Overlay pairs the terrain shader reads (`Globals.emissive_layers`, terrain.wgsl).
pub const MAX: usize = 128;
/// Native mods' own `emissive/` textures, as their API documents.
const NATIVE: usize = 32;
/// Load only bounded, explicitly named emissive textures from the active pack.
pub fn load(root: &Path, sprites: &mut Sprites) {
@@ -12,7 +17,7 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
name.split_once(':')
.is_some_and(|(_, p)| p.starts_with("emissive/"))
})
.take(MAX)
.take(NATIVE)
.collect();
names.sort();
for name in names {
@@ -23,6 +28,80 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
}
}
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
for layer in sprites.layers.values() {
*sharing.entry(*layer).or_default() += 1;
}
let bases: Vec<String> = sprites
.layers
.iter()
.filter(|(name, layer)| {
sharing[layer] == 1
&& name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
})
.map(|(name, _)| name.clone())
.collect();
let mut pairs = Vec::new();
for base in bases {
let overlay = format!("{base}{suffix}");
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
continue;
}
if pairs.len() == MAX - NATIVE {
log::warn!(
"Horizon: more than {} emissive block overlays: {overlay} and later skipped",
MAX - NATIVE
);
break;
}
if std::fs::metadata(&file).is_ok_and(|m| m.len() > 1 << 20) {
log::warn!("Horizon: emissive overlay {overlay} is over 1 MiB, skipped");
continue;
}
// A file that doesn't load would glow as the missing texture: no pair instead.
match sprites.try_insert(root, &overlay) {
Ok(_) => pairs.push((base, overlay)),
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
}
}
pairs
}
/// The overlay layer's flag (its high bit) for an OptiFine overlay, which draws cutout: opaque
/// where its alpha is at least 0.1 (OptiFine renders SOLID textures' overlays as CUTOUT).
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
native: impl IntoIterator<Item = (&'a str, &'a str)>,
optifine: &'a [(String, String)],
) -> Vec<[u32; 4]> {
let mut by_base: BTreeMap<u32, [u32; 4]> = BTreeMap::new();
for pair in mappings(layers, native.into_iter().take(NATIVE)) {
by_base.insert(pair[0], pair);
}
let optifine = optifine.iter().map(|(b, o)| (b.as_str(), o.as_str()));
for [b, o, z, w] in mappings(layers, optifine) {
by_base.entry(b).or_insert([b, o | CUTOUT, z, w]);
}
by_base.into_values().collect()
}
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
pub fn mappings<'a>(
layers: &BTreeMap<String, u32>,
@@ -40,6 +119,120 @@ pub fn mappings<'a>(
#[cfg(test)]
mod tests {
use super::*;
/// A 16x16 image of one colour, `shade` its red (images of different shades never share a
/// sprite layer).
fn png(path: &Path, shade: u8) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::io::BufWriter::new(std::fs::File::create(path).unwrap());
let mut encoder = png::Encoder::new(file, 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let pixels: Vec<u8> = (0..16 * 16).flat_map(|_| [shade, 9, 9, 255]).collect();
writer.write_image_data(&pixels).unwrap();
}
#[test]
fn optifine_overlays_pair_with_their_loaded_blocks() {
let root =
std::env::temp_dir().join(format!("horizon-block-emissive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let block = root.join("assets/minecraft/textures/block");
let names = [
"stone",
"stone_e",
"dirt",
"glowstone_e",
"deepslate",
"deepslate_glow",
"andesite",
"granite",
];
for (i, name) in names.iter().enumerate() {
png(&block.join(format!("{name}.png")), i as u8 * 20);
}
// Same pixels as andesite: the two share a layer, so neither gets an overlay.
png(&block.join("diorite.png"), 6 * 20);
png(&block.join("diorite_e.png"), 200);
// A broken overlay: skipped, not drawn as the missing texture.
std::fs::write(block.join("granite_e.png"), b"not a png").unwrap();
png(&root.join("assets/minecraft/textures/item/stick.png"), 210);
png(
&root.join("assets/minecraft/textures/item/stick_e.png"),
220,
);
let mut sprites = Sprites::default();
for name in [
"stone",
"dirt",
"deepslate",
"andesite",
"diorite",
"granite",
] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/stone".into(),
"minecraft:block/stone_e".into()
)]
);
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
"minecraft:block/deepslate".into(),
"minecraft:block/deepslate_glow".into()
)]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn the_table_keeps_native_pairs_first_and_flags_optifine_cutout() {
let layers: BTreeMap<String, u32> =
[("a", 5), ("a_e", 6), ("b", 2), ("b_e", 3), ("native", 9)]
.map(|(n, l)| (n.to_owned(), l))
.into();
let optifine = [
("a".to_owned(), "a_e".to_owned()),
("b".to_owned(), "b_e".to_owned()),
];
// A native mod pairs `a` too: its pair wins; `b` stays the pack's, flagged.
let t = table(&layers, [("a", "native")], &optifine);
assert_eq!(
t,
[[2, 3 | CUTOUT, 0, 0], [5, 9, 0, 0]],
"sorted by base layer"
);
// Native mods keep their 32: of 40 requests on 40 bases, the first 32.
let layers: BTreeMap<String, u32> =
(0..41u32).map(|i| (format!("s{i}"), 100 + i)).collect();
let names: Vec<String> = (1..41).map(|i| format!("s{i}")).collect();
let many = names.iter().map(|n| (n.as_str(), "s0"));
let t = table(&layers, many, &[]);
assert_eq!(t.len(), 32);
assert_eq!(
t.last(),
Some(&[132, 100, 0, 0]),
"s32 last; s33 to s40 cut"
);
}
#[test]
fn only_existing_pairs_are_mapped() {
let layers = [
@@ -3358,7 +3358,7 @@ impl OptionsUi {
let m = crate::screen_draw::Measure(&atlas.font);
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
let packs = self.packs(atlas, (w, h));
let mut draws = packs.draw(&m, mouse, blink);
let mut draws = packs.draw(&m, mouse, blink, crate::hud_overlays::now_ms());
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
crate::screen_draw::paint(batch, atlas, &draws);
return;
+155 -13
View File
@@ -827,7 +827,7 @@ impl PackScreen {
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
/// is the time since the search box took the focus.
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64) -> Vec<Draw> {
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64, now_ms: i64) -> Vec<Draw> {
let mut out = Vec::new();
// PackSelectionScreen, or the ConfirmScreen over it (its title and
// its message).
@@ -845,7 +845,7 @@ impl PackScreen {
for ((bx, by), key) in buttons.into_iter().zip(["gui.yes", "gui.no"]) {
let hover = d::inside(mouse, bx, by, 150, 20);
crate::widgets::widget_cursor(hover, true);
d::button(&mut out, m, &tr(key), bx, by, 150, 20, true, hover);
d::button(&mut out, m, &tr(key), bx, by, 150, 20, true, hover, now_ms);
}
return out;
}
@@ -863,25 +863,40 @@ impl PackScreen {
for side in 0..2 {
self.draw_list(&mut out, m, side, mouse);
}
for (i, (bx, by)) in self.footer().into_iter().enumerate() {
self.draw_footer(
&mut out,
m,
mouse,
now_ms,
[&tr("pack.openFolder"), &tr("gui.done")],
);
out
}
/// The footer's two buttons, "Open Pack Folder" and "Done", over their
/// labels; a label wider than its button scrolls with the clock.
fn draw_footer(
&self,
out: &mut Vec<Draw>,
m: &Measure,
mouse: (i32, i32),
now_ms: i64,
labels: [&Text; 2],
) {
for (i, ((bx, by), label)) in self.footer().into_iter().zip(labels).enumerate() {
let hover = d::inside(mouse, bx, by, 150, 20);
let label = tr(if i == 0 {
"pack.openFolder"
} else {
"gui.done"
});
// The cursor follows the mouse; the highlight also keyboard focus.
crate::widgets::widget_cursor(hover, true);
d::button(
&mut out,
out,
m,
&label,
label,
bx,
by,
150,
20,
true,
hover || self.focus == Focus::Button(i),
now_ms,
);
if i == 0 {
crate::widgets::last_narratable(|n| {
@@ -889,10 +904,9 @@ impl PackScreen {
});
}
if i == 0 && hover {
d::tooltip(&mut out, m, &tr("pack.folderInfo"), mouse);
d::tooltip(out, m, &tr("pack.folderInfo"), mouse);
}
}
out
}
fn draw_list(&self, out: &mut Vec<Draw>, m: &Measure, side: usize, mouse: (i32, i32)) {
let l = &self.lists[side];
@@ -1151,7 +1165,7 @@ mod icon_tests {
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut draws = screen.draw(&m, (0, 0), 0, 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
let icons: Vec<&str> = draws
@@ -1242,3 +1256,131 @@ mod click_sound_tests {
assert!(!screen.confirming());
}
}
#[cfg(test)]
mod footer_label_tests {
use super::*;
use crate::lang::{self, Language};
/// The footer drawn with these labels (no global language involved) and
/// the footer buttons' positions.
fn footer(
atlas: &HudAtlas,
open_folder: &Text,
done: &Text,
now_ms: i64,
) -> (Vec<Draw>, [(i32, i32); 2]) {
let m = Measure(&atlas.font);
let screen = PackScreen::new(&m, Text::literal("Packs"), 480, 320, false, Vec::new(), &[]);
let mut out = Vec::new();
screen.draw_footer(&mut out, &m, (0, 0), now_ms, [open_folder, done]);
(out, screen.footer())
}
/// Every label drawn in `[bx + 2, bx + 148] x [by, by + 20]` of its button:
/// the scissor of a scrolling one is that box, and the text sits between
/// the scissor's calls.
fn assert_labels_stay_in_their_buttons(draws: &[Draw], buttons: &[(i32, i32)]) -> usize {
let mut scissors = 0;
let mut open = None;
let mut button = 0;
for d in draws {
match d {
Draw::Sprite { x, y, .. } => {
button = buttons.iter().position(|b| *b == (*x, *y)).unwrap();
}
Draw::Scissor { x0, y0, x1, y1 } => {
let (bx, by) = buttons[button];
assert_eq!([*x0, *y0, *x1, *y1], [bx + 2, by, bx + 148, by + 20]);
assert!(open.replace(()).is_none());
scissors += 1;
}
Draw::NoScissor => assert!(open.take().is_some()),
_ => {}
}
}
assert!(open.is_none());
scissors
}
/// The Resource Packs footer with a label wider than its 150 px button
/// does not panic and scrolls; the short "Done" keeps its centre.
#[test]
fn a_footer_label_wider_than_its_button_scrolls() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let wide = Text::literal("Otvori papku s paketami resursov, i jos nesto");
assert!(m.width(&wide) > 146);
let (draws, buttons) = footer(&atlas, &wide, &Text::literal("Done"), 0);
assert_eq!(assert_labels_stay_in_their_buttons(&draws, &buttons), 1);
// The clock moves it.
let x = |draws: &[Draw]| {
draws
.iter()
.find_map(|d| match d {
Draw::Text { x, .. } => Some(*x),
_ => None,
})
.unwrap()
};
let (later, _) = footer(&atlas, &wide, &Text::literal("Done"), 2000);
assert_ne!(x(&draws), x(&later));
// Both too wide: both scroll.
let (draws, buttons) = footer(&atlas, &wide, &wide, 0);
assert_eq!(assert_labels_stay_in_their_buttons(&draws, &buttons), 2);
}
/// The real language files: the Resource Packs footer in each of the 143
/// languages draws without a panic, and in the ones whose "Open Pack
/// Folder" is wider than the button (Afrikaans and Macedonian among them,
/// the crash of #6129) it scrolls. A local `Language` per file: the global
/// language stays as it was.
#[test]
#[ignore = "requires prepared language data in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3"]
fn the_footer_draws_in_every_language_file() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let atlas = crate::hud::HudAtlas::build(&root).unwrap();
let m = Measure(&atlas.font);
let before = lang::code();
let mut codes: Vec<String> = std::fs::read_dir(root.join("assets/minecraft/lang"))
.unwrap()
.filter_map(|e| {
let name = e.ok()?.file_name().into_string().ok()?;
name.strip_suffix(".json").map(str::to_owned)
})
.collect();
codes.sort();
assert!(codes.len() >= 143, "{}", codes.len());
let mut scrolling = Vec::new();
for code in &codes {
let language = Language::load(&[&root], code);
let label = |key| Text::literal(language.get(key).unwrap_or(key));
let (open_folder, done) = (label("pack.openFolder"), label("gui.done"));
for now_ms in [0, 700, 1300, 5000] {
let (draws, buttons) = footer(&atlas, &open_folder, &done, now_ms);
let n = assert_labels_stay_in_their_buttons(&draws, &buttons);
assert_eq!(
n,
[&open_folder, &done]
.iter()
.filter(|t| m.width(t) > 146)
.count(),
"{code}"
);
if now_ms == 0 && n > 0 {
scrolling.push(code.as_str());
}
}
}
for code in ["af_za", "mk_mk"] {
assert!(scrolling.contains(&code), "{code} not in {scrolling:?}");
}
assert_eq!(lang::code(), before, "the global language was left alone");
}
}
+102 -20
View File
@@ -695,18 +695,20 @@ fn build_overlay_uncached(
merged.insert(name.clone(), bytes);
}
}
/// A zip entry to write: its pack, its name in the zip and where it goes.
type Extract<'a> = (usize, &'a str, PathBuf);
#[allow(clippy::too_many_arguments)]
fn mirror(
fn mirror<'a>(
rel: &str,
vanilla: &Path,
out: &Path,
touched: &BTreeSet<String>,
hidden: &BTreeSet<String>,
files: &BTreeMap<String, (usize, Provided)>,
files: &'a BTreeMap<String, (usize, Provided)>,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
zips: &[Option<Zip>],
dependencies: &mut cache::Dependencies,
extract: &mut Vec<Extract<'a>>,
) -> io::Result<()> {
let src = if rel.is_empty() {
vanilla.to_path_buf()
@@ -733,7 +735,13 @@ fn build_overlay_uncached(
} else {
format!("{rel}/")
};
for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) {
// The files under this directory are one run of the sorted map: walking only that run
// keeps a pack of tens of thousands of files in thousands of folders from being
// scanned once per folder.
for f in files
.range(prefix.clone()..)
.map_while(|(f, _)| f.strip_prefix(&prefix))
{
names.insert(f.split('/').next().unwrap_or("").to_owned());
}
for name in names {
@@ -747,19 +755,8 @@ fn build_overlay_uncached(
let target = out.join(&child);
match path {
Some(p) => link(p, &target)?,
None => {
if let Some(Some(z)) = zips.get(*owner) {
// One unreadable entry costs that file, not the
// pack: an error here failed the build and the
// reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(&target, bytes)?,
Err(e) => log::warn!(
"Horizon: skipping unreadable pack entry {inner}: {e}"
),
}
}
}
// Inflated and written after the walk, on every thread.
None => extract.push((*owner, inner.as_str(), target)),
}
} else if filters.is_empty()
&& !touched.contains(&child)
@@ -783,8 +780,8 @@ fn build_overlay_uncached(
files,
merged,
filters,
zips,
dependencies,
extract,
)?;
} else if filters.iter().any(|f| f.blocks(&child)) {
// Hidden by a pack's filter.
@@ -798,6 +795,7 @@ fn build_overlay_uncached(
}
Ok(())
}
let mut extract = Vec::new();
mirror(
"",
vanilla,
@@ -807,9 +805,27 @@ fn build_overlay_uncached(
&files,
&merged,
&filters,
&zips,
dependencies,
)
&mut extract,
)?;
// Inflating and writing a big pack's files one by one was most of a cold build; its
// folders all exist by now.
crate::par::par_map(&extract, |(owner, inner, target)| {
let Some(Some(z)) = zips.get(*owner) else {
return Ok(());
};
// One unreadable entry costs that file, not the pack: an error here
// failed the build and the reload dropped every server pack in the stack.
match z.read(inner) {
Ok(bytes) => fs::write(target, bytes),
Err(e) => {
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
Ok(())
}
}
})
.into_iter()
.collect()
}
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
/// than from the top pack only.
@@ -1489,6 +1505,72 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
// Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside
// folders of almost their name: what a prefix lookup could mix up.
let names: Vec<String> = (0..400)
.map(|i| {
let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5];
format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3)
})
.chain(["assets/ns0/textures/item.png".to_owned()])
.collect();
let mut expected: BTreeMap<String, Vec<u8>> = BTreeMap::new();
for name in names.iter().step_by(4) {
let path = vanilla.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("vanilla {name}")).unwrap();
expected.insert(name.clone(), format!("vanilla {name}").into_bytes());
}
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
// Packs a and b are zips, c a folder; each provides a different part of the names.
fs::create_dir_all(base.join("packs/c")).unwrap();
fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap();
for (pack, every) in [("a", 2), ("b", 3)] {
let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap();
let mut zip = zip::ZipWriter::new(file);
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(meta).unwrap();
for name in names.iter().step_by(every) {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(format!("{pack} {name}").as_bytes()).unwrap();
expected.insert(name.clone(), format!("{pack} {name}").into_bytes());
}
zip.finish().unwrap();
}
for name in names.iter().skip(1).step_by(5) {
let path = base.join("packs/c").join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, format!("c {name}")).unwrap();
expected.insert(name.clone(), format!("c {name}").into_bytes());
}
let all = folder(&base.join("packs"), RESOURCE_FORMAT);
let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from);
let out = base.join("overlay");
build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap();
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, root, out);
} else {
let name = path.strip_prefix(root).unwrap().to_string_lossy();
out.insert(name.replace('\\', "/"), fs::read(&path).unwrap());
}
}
}
let mut built = BTreeMap::new();
walk(&out.join("assets"), &out, &mut built);
assert_eq!(built, expected);
let _ = fs::remove_dir_all(base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
@@ -1659,7 +1659,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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()
}
+283 -7
View File
@@ -409,8 +409,104 @@ pub fn label(
}
}
/// `Button` (`extractDefaultSprite` and the label over its contents, which
/// must fit: scrolling labels depend on the clock).
/// `ActiveTextCollector.PERIOD_PER_SCROLLED_PIXEL`.
const PERIOD_PER_SCROLLED_PIXEL: f64 = 0.5;
/// `ActiveTextCollector.MIN_SCROLL_PERIOD`.
const MIN_SCROLL_PERIOD: f64 = 3.0;
/// How far `ActiveTextCollector.defaultScrollingHelper` has slid a line left
/// of the box's left edge at `now_ms` (`Util.getMillis`), for a line that
/// overflows the box by `max_position` pixels. A whole cycle takes
/// `max(max_position * 0.5, 3)` seconds, eased with a sine, so the line rests
/// a moment at each end; at time 0 it is at the far end.
pub fn scroll_offset(max_position: i32, now_ms: i64) -> i32 {
let time = now_ms as f64 / 1000.0;
let period = (f64::from(max_position) * PERIOD_PER_SCROLLED_PIXEL).max(MIN_SCROLL_PERIOD);
let alpha = horizon_core::jmath::sin(
(std::f64::consts::PI / 2.0)
* horizon_core::jmath::cos(std::f64::consts::PI * 2.0 * time / period),
) / 2.0
+ 0.5;
// `Mth.lerp(alpha, 0.0, maxPosition)`, then the `(int)` cast.
(0.0 + alpha * (f64::from(max_position) - 0.0)) as i32
}
/// `ActiveTextCollector.defaultScrollingHelper` with the line height of 9
/// that `acceptScrolling` passes: a message that fits is centred on
/// `center_x`, kept inside `[left, right]`; a wider one is left-aligned,
/// clipped to the box and slides back and forth with the clock (`now_ms`).
/// Vanilla intersects that clip with the enclosing scissor; `paint` does not
/// stack scissors, so this must not be drawn inside another `Draw::Scissor`.
pub fn scrolling_text(
out: &mut Vec<Draw>,
m: &Measure,
message: &Text,
center_x: i32,
left: i32,
right: i32,
top: i32,
bottom: i32,
now_ms: i64,
) {
let line_width = m.width(message);
let text_top = (top + bottom - 9) / 2 + 1;
let available = right - left;
if line_width > available {
let pos = scroll_offset(line_width - available, now_ms);
out.push(Draw::Scissor {
x0: left,
y0: top,
x1: right,
y1: bottom,
});
text(out, message.clone(), left - pos, text_top, WHITE, true);
out.push(Draw::NoScissor);
} else {
// Here `left + line_width / 2 <= right - line_width / 2`, so the
// clamp's bounds are in order.
let text_x = center_x.clamp(left + line_width / 2, right - line_width / 2);
let at = center_left(text_x, line_width);
text(out, message.clone(), at, text_top, WHITE, true);
}
}
/// `ActiveTextCollector.acceptScrollingWithDefaultCenter`: `scrolling_text`
/// centred between `left` and `right`.
pub fn scrolling_with_default_center(
out: &mut Vec<Draw>,
m: &Measure,
message: &Text,
left: i32,
right: i32,
top: i32,
bottom: i32,
now_ms: i64,
) {
let center = (left + right) / 2;
scrolling_text(out, m, message, center, left, right, top, bottom, now_ms);
}
/// `AbstractWidget.extractScrollingStringOverContents`: `message` over the
/// widget's box less `margin` on each side (centred, or scrolling when too
/// wide).
pub fn scrolling_string_over_contents(
out: &mut Vec<Draw>,
m: &Measure,
message: &Text,
x: i32,
y: i32,
w: i32,
h: i32,
margin: i32,
now_ms: i64,
) {
let (left, right) = (x + margin, x + w - margin);
scrolling_with_default_center(out, m, message, left, right, y, y + h, now_ms);
}
/// `Button` (`extractDefaultSprite`, then `AbstractButton.extractDefaultLabel`:
/// `extractScrollingStringOverContents` with a margin of 2, which scrolls a
/// label wider than the button with the clock, `now_ms`).
pub fn button(
out: &mut Vec<Draw>,
m: &Measure,
@@ -421,6 +517,7 @@ pub fn button(
h: i32,
active: bool,
highlighted: bool,
now_ms: i64,
) {
// Its narration: hovered is the mouse over it (`isHovered`), which the
// narrator tests.
@@ -439,11 +536,7 @@ pub fn button(
"widget/button"
};
sprite(out, sprite_id, x, y, w, h);
let (left, right) = (x + 2, x + w - 2);
let lw = m.width(label);
let top = (y + y + h - 9) / 2 + 1;
let cx = ((left + right) / 2).clamp(left + lw / 2, right - lw / 2);
text(out, label.clone(), center_left(cx, lw), top, WHITE, true);
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
}
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
pub fn tooltip(out: &mut Vec<Draw>, m: &Measure, message: &Text, mouse: (i32, i32)) {
@@ -962,3 +1055,186 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
crate::ui::tooltip(batch, atlas, &lines, at);
}
}
#[cfg(test)]
mod button_label_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// A label made of `n` copies of one letter.
fn label(n: usize) -> Text {
Text::literal("W".repeat(n))
}
/// The calls of one button: its sprite, then (the scissor,) the label.
fn draw(m: &Measure, text: &Text, w: i32, now_ms: i64) -> Vec<Draw> {
let mut out = Vec::new();
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
out
}
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
draws.iter().find_map(|d| match d {
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
_ => None,
})
}
fn text_x(draws: &[Draw]) -> i32 {
draws
.iter()
.find_map(|d| match d {
Draw::Text { x, .. } => Some(*x),
_ => None,
})
.expect("the label is drawn")
}
/// A button `overflow` px too narrow for `text`, whatever the font.
fn width_overflowing_by(m: &Measure, text: &Text, overflow: i32) -> i32 {
m.width(text) - overflow + 4
}
/// `defaultScrollingHelper`: `pos` runs from the far end (`maxPosition`, at
/// time 0) to 0 at half the period (`max(maxPosition * 0.5, 3)` seconds)
/// and back, with `maxPosition / 2` a quarter of the way.
#[test]
fn the_offset_follows_the_vanilla_period_and_easing() {
// 8 px over: a 4 s period. At 3 s the cosine is a hair below 0, so
// `pos` is a hair below 4 and the `(int)` cast truncates it to 3.
let at = |ms| scroll_offset(8, ms);
assert_eq!(
[at(0), at(1000), at(2000), at(3000), at(4000)],
[8, 4, 0, 3, 8]
);
// 1 px over: the 3 s minimum period, so 1.5 s is the other end.
assert_eq!([scroll_offset(1, 0), scroll_offset(1, 1500)], [1, 0]);
// 40 px over: a 20 s period.
assert_eq!(
[
scroll_offset(40, 0),
scroll_offset(40, 5000),
scroll_offset(40, 10000),
scroll_offset(40, 20000)
],
[40, 20, 0, 40]
);
// It never leaves [0, maxPosition], and the minimum period is 3 s even
// for a tiny overflow.
for max in 1..200 {
for ms in (0..60_000).step_by(37) {
let pos = scroll_offset(max, ms);
assert!((0..=max).contains(&pos), "{max} {ms} {pos}");
}
}
assert_eq!(scroll_offset(2, 1500), 0);
// tools/test_pack_screen_parity.py runs vanilla's own
// `defaultScrollingHelper` at fixed clocks for the scrolling labels.
}
/// The bug: a label wider than the button panicked in `i32::clamp`. Now it
/// is left-aligned, clipped to the button's contents and slides with the
/// clock, and every pixel of it stays inside that box.
#[test]
fn a_label_wider_than_its_button_scrolls_inside_its_box() {
let atlas = atlas();
let m = Measure(&atlas.font);
let text = label(24);
let w = width_overflowing_by(&m, &text, 8);
let (left, right) = (10 + 2, 10 + w - 2);
for (ms, x) in [(0, left - 8), (1000, left - 4), (2000, left)] {
let draws = draw(&m, &text, w, ms);
assert_eq!(scissor_of(&draws), Some([left, 20, right, 40]), "{ms}");
assert_eq!(text_x(&draws), x, "{ms}");
// The scissor wraps the text and nothing else.
assert!(matches!(draws[1], Draw::Scissor { .. }));
assert!(matches!(draws[2], Draw::Text { .. }));
assert_eq!(draws[3], Draw::NoScissor);
assert_eq!(draws.len(), 4);
// Painted, no vertex of the label leaves the box.
let mut batch = HudBatch::new([320, 240]);
paint(&mut batch, &atlas, &draws[1..]);
assert!(!batch.vertices.is_empty());
for v in &batch.vertices {
assert!(
(left as f32..=right as f32).contains(&v.pos[0])
&& (20.0..=40.0).contains(&v.pos[1]),
"{ms}: {:?}",
v.pos
);
}
}
}
/// A label as wide as the room is centred as before and takes no scissor;
/// one pixel more scrolls.
#[test]
fn the_scroll_starts_one_pixel_past_the_room() {
let atlas = atlas();
let m = Measure(&atlas.font);
let text = label(10);
let lw = m.width(&text);
let fits = draw(&m, &text, lw + 4, 0);
assert_eq!(scissor_of(&fits), None);
assert_eq!(text_x(&fits), 10 + 2);
assert_eq!(fits.len(), 2);
let over = draw(&m, &text, lw + 3, 0);
assert_eq!(scissor_of(&over), Some([12, 20, 10 + lw + 3 - 2, 40]));
// maxPosition 1: the far end at time 0, the near one 1.5 s later.
assert_eq!(text_x(&over), 12 - 1);
assert_eq!(text_x(&draw(&m, &text, lw + 3, 1500)), 12);
}
/// A label that fits stays where it was: centred on the button.
#[test]
fn a_label_that_fits_is_centred() {
let atlas = atlas();
let m = Measure(&atlas.font);
let text = label(6);
let lw = m.width(&text);
for w in [lw + 5, lw + 6, 150, 200] {
let draws = draw(&m, &text, w, 12345);
assert_eq!(scissor_of(&draws), None);
let (left, right) = (10 + 2, 10 + w - 2);
assert_eq!(text_x(&draws), (left + right) / 2 - lw / 2, "{w}");
}
}
/// An empty label, and a button too narrow to hold anything (a 1-px one
/// has a negative room), neither panic nor draw outside it.
#[test]
fn empty_labels_and_tiny_buttons_do_not_panic() {
let atlas = atlas();
let m = Measure(&atlas.font);
for text in [Text::empty(), label(1), label(30)] {
for w in [-5, 0, 1, 2, 3, 4, 5, 150] {
for ms in [0, 1, 1499, 1500, 123_456_789] {
let draws = draw(&m, &text, w, ms);
let mut batch = HudBatch::new([320, 240]);
paint(&mut batch, &atlas, &draws);
if let Some([x0, _, x1, _]) = scissor_of(&draws) {
// Whatever scrolled is clipped to the (maybe empty) box.
for v in batch.vertices.iter().skip(sprite_vertices(&atlas, w)) {
assert!(
x1 <= x0 || (x0 as f32..=x1 as f32).contains(&v.pos[0]),
"{w} {ms}: {:?}",
v.pos
);
}
}
}
}
}
// 4 px wide leaves no room: an empty label fits it (0 <= 0).
assert_eq!(scissor_of(&draw(&m, &Text::empty(), 4, 0)), None);
// 1 px wide: the room is -3 px, even an empty label overflows it.
assert!(scissor_of(&draw(&m, &Text::empty(), 1, 0)).is_some());
}
/// The vertices of the button's sprite alone.
fn sprite_vertices(atlas: &HudAtlas, w: i32) -> usize {
let mut batch = HudBatch::new([320, 240]);
let mut out = Vec::new();
sprite(&mut out, "widget/button", 10, 20, w, 20);
paint(&mut batch, atlas, &out);
batch.vertices.len()
}
}
@@ -44,6 +44,9 @@ pub struct ScreenQuad {
pub blend: Blend,
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
pub sampling: u32,
/// A tint of the whole view (the underwater overlay), not something in front of it: in HDR
/// output the world shines through it (`Renderer::draw_overlay`).
pub world_tint: bool,
}
/// What the camera sees this frame.
@@ -160,6 +163,7 @@ impl ScreenEffects {
}),
blend: Blend::Alpha,
sampling,
world_tint: false,
};
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
@@ -180,13 +184,16 @@ impl ScreenEffects {
{
let (u, v) = (-yaw / 64.0, pitch / 64.0);
let uv = [[u + 4.0, v + 4.0], [u, v + 4.0], [u, v], [u + 4.0, v]];
out.push(hand_quad(
full,
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
));
out.push(ScreenQuad {
world_tint: true,
..hand_quad(
full,
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
)
});
}
if input.on_fire
&& let Some(rect) = sprite("block/fire_1")
@@ -224,6 +231,7 @@ impl ScreenEffects {
}),
blend,
sampling,
world_tint: false,
};
let rgba = |c: u32| {
let [a, r, g, b] = c.to_be_bytes();
@@ -397,6 +405,9 @@ mod tests {
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
// Only the underwater overlay tints the world; the block in the way covers it.
let tints: Vec<_> = quads.iter().map(|q| q.world_tint).collect();
assert_eq!(tints, [false, true, false]);
}
#[test]
fn mcmeta_texture_section() {
+155
View File
@@ -92,6 +92,161 @@ pub fn describe(spans: &[Span]) -> String {
list.join(", ")
}
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
/// the start (resource tables, the renderer, the title, a world's open, a
/// server's join, a dimension change) on whichever thread ran it, one Chrome
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
/// Perfetto opens it). Times are microseconds since the process started. Off,
/// a step costs one relaxed load.
pub mod startup {
use std::{
io::Write as _,
sync::{
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
static ORIGIN: OnceLock<Instant> = OnceLock::new();
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
thread_local! {
/// This thread's trace id, given (with its name) on its first event.
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
/// (the process's start). Call once, first thing.
pub fn init(origin: Instant) {
ORIGIN.get_or_init(|| origin);
let sink = SINK.get_or_init(|| {
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
std::fs::File::create(&path).ok().map(Mutex::new)
});
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
ON.load(Ordering::Relaxed)
}
fn micros(at: Instant) -> u128 {
ORIGIN.get().map_or(0, |origin| {
at.saturating_duration_since(*origin).as_micros()
})
}
fn write(event: std::fmt::Arguments) {
let Some(Some(sink)) = SINK.get() else {
return;
};
let tid = THREAD.with(|id| {
if id.get() == 0 {
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
let name = std::thread::current()
.name()
.unwrap_or("unnamed")
.replace(['"', '\\'], "_");
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(
file,
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
id.get()
);
}
id.get()
});
let mut file = sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
}
/// A step running until it is dropped.
#[must_use = "the step ends when this is dropped"]
pub struct Step {
name: &'static str,
started: Instant,
}
impl Drop for Step {
fn drop(&mut self) {
let end = Instant::now();
write(format_args!(
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
self.name,
micros(self.started),
end.saturating_duration_since(self.started).as_micros()
));
}
}
/// Starts a step named `name` (a literal: no quotes or backslashes).
#[inline]
pub fn step(name: &'static str) -> Option<Step> {
on().then(|| Step {
name,
started: Instant::now(),
})
}
/// Runs `f` as a step named `name`.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _step = step(name);
f()
}
/// A moment of the start: the title drawn, the world in view.
#[inline]
pub fn mark(name: &'static str) {
if on() {
write(format_args!(
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
micros(Instant::now())
));
}
}
/// The first time `name` happens, as a mark. `once` is the caller's flag.
#[inline]
pub fn mark_once(once: &AtomicBool, name: &'static str) {
if on() && !once.swap(true, Ordering::Relaxed) {
mark(name);
}
}
/// Set while a world opens, a server is joined or the dimension changes,
/// until the view first fills ([`view_filled`]).
static FILLING: AtomicBool = AtomicBool::new(false);
/// A world starts loading: the next full view is marked.
pub fn arm(name: &'static str) {
if on() {
mark(name);
FILLING.store(true, Ordering::Relaxed);
}
}
/// Called each frame in a world: marks `world.view_full` the first frame
/// after [`arm`] where nothing waits to load, generate, light, mesh or
/// upload and the resident columns cover perf_bench's share of the view
/// (0.8 pi r^2: corners are never loaded).
#[inline]
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
if FILLING.load(Ordering::Relaxed)
&& pending == 0
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
&& FILLING.swap(false, Ordering::Relaxed)
{
mark("world.view_full");
}
}
}
#[cfg(test)]
mod tests {
use super::Span;
+236 -14
View File
@@ -99,6 +99,9 @@ pub struct Sprites {
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
/// resource path.
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
@@ -242,6 +245,8 @@ impl Sprites {
self.lay_out(false);
}
fn lay_out(&mut self, pixels: bool) {
// Prefetched textures nothing inserted.
self.ready = Default::default();
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
@@ -297,6 +302,14 @@ impl Sprites {
}
}
}
/// [`Sprites::insert`] without the missing-texture fallback: a file that doesn't load is an
/// error, and nothing is added.
pub fn try_insert(&mut self, root: &Path, name: &str) -> Result<u32> {
match self.layers.get(name) {
Some(layer) => Ok(*layer),
None => self.load(root, name),
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
/// vanilla's missing texture, as in a stitched atlas.
@@ -335,13 +348,50 @@ impl Sprites {
}
}
}
/// Decode on every thread the textures about to be inserted: sprite names, or resource
/// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds
/// many (the block models, an OptiFine pack's tiles) calls this first and then inserts
/// them in its own order, so the layers are the same as without it. What is already a
/// layer or in the disk cache is skipped; what fails to decode is left for the insert,
/// which reports it.
pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator<Item = &'a str>) {
let names: Vec<&str> = names
.into_iter()
.filter(|name| {
!self.layers.contains_key(*name)
&& !self.ready.contains_key(*name)
&& self.disk.as_ref().is_none_or(|d| d.get(name).is_none())
})
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let decoded = crate::par::par_map(&names, |name| {
if name.contains(':') {
Self::decode(root, name).ok()
} else {
Self::decode_file(&root.join(name), name).ok()
}
});
for (name, decoded) in names.into_iter().zip(decoded) {
if let Some(decoded) = decoded {
self.ready.insert(name.to_owned(), decoded);
}
}
}
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
// Custom Item Textures' files are outside `textures/`, which the disk cache's
// fingerprint covers: like `insert_file`'s, they skip it.
let cit = name.starts_with("horizon_cit:");
let disk = self.disk.as_mut().filter(|_| !cit);
let cached = disk.as_ref().and_then(|d| d.get(name)).cloned();
let decoded = match cached {
Some(decoded) => decoded,
None => {
let decoded = Self::decode(root, name)?;
if let Some(disk) = &mut self.disk {
let decoded = match self.ready.remove(name) {
Some(decoded) => decoded,
None => Self::decode(root, name)?,
};
if let Some(disk) = disk {
disk.insert(name, decoded.clone());
}
decoded
@@ -357,16 +407,20 @@ impl Sprites {
if let Some(layer) = self.layers.get(resource) {
return Ok(*layer);
}
let decoded = Self::decode_file(&root.join(resource), resource)?;
let decoded = match self.ready.remove(resource) {
Some(decoded) => decoded,
None => Self::decode_file(&root.join(resource), resource)?,
};
self.register(root, resource, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
Self::decode_file(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
name,
)
if !name.contains(':') {
return Err("invalid sprite identifier".into());
}
// `texture_path`, so Custom Item Textures' `horizon_cit:` sprites find their file.
let path = crate::atlas_sources::texture_path(root, name, "textures");
Self::decode_file(&path, name)
}
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
@@ -462,8 +516,8 @@ impl Sprites {
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
Some(_) if labpbr_maps() => {
let path = crate::atlas_sources::texture_path(root, name, "textures");
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
@@ -473,9 +527,11 @@ impl Sprites {
strategy,
alpha_cutoff_bias,
has_transparent,
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
// Custom Item Textures' ids are all item textures.
item: name.starts_with("horizon_cit:")
|| name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
@@ -706,6 +762,172 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn custom_item_texture_ids_load_with_their_animation() {
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
let file = root.join("assets/minecraft/optifine/cit/a.png");
fs::create_dir_all(file.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), 16, 48);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..16 * 48).flat_map(|_| [200, 10, 10, 255]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
let mut sprites = Sprites::default();
sprites
.insert(&root, "horizon_cit:minecraft/optifine/cit/a")
.unwrap();
fs::remove_dir_all(&root).unwrap();
// The missing texture would not animate: this one is the file, in three frames.
assert_eq!(sprites.animations.len(), 1);
assert_eq!(sprites.animations[0].frame_count(), 3);
}
#[test]
fn custom_item_textures_skip_the_disk_cache() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-cit-disk-{}", std::process::id()));
// A realistic root: the cache's fingerprint walks the `textures` trees.
let vanilla = root.join("assets/minecraft/textures/item/stick.png");
let file = root.join("assets/minecraft/optifine/cit/a.png");
let write = |path: &Path, red: u8| {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..256).flat_map(|_| [red, 0, 0, 255]).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
write(&vanilla, 1);
write(&file, 10);
let id = "horizon_cit:minecraft/optifine/cit/a";
let red = |sprites: &Sprites, layer: u32| sprites.native[layer as usize].1[0][0];
let mut first = Sprites::with_disk_cache(&root);
let layer = first.insert(&root, id).unwrap();
assert_eq!(red(&first, layer), 10);
first.save_disk_cache();
// The pack's file changes; `textures/` does not, so a cached copy would still match.
write(&file, 20);
let mut second = Sprites::with_disk_cache(&root);
let layer = second.insert(&root, id).unwrap();
fs::remove_dir_all(&root).unwrap();
assert_eq!(red(&second, layer), 20);
}
#[test]
fn custom_item_textures_mip_as_items() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-cit-mip-{}", std::process::id()));
let write = |rel: &str| {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
// Any pixels: whether a texture mips as an item is part of the shared-layer key.
let pixels: Vec<u8> = (0..256)
.flat_map(|i| [9, 9, 9, if i % 2 == 0 { 255 } else { 0 }])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
write("assets/a/textures/block/x.png");
write("assets/a/textures/item/x.png");
write("assets/minecraft/optifine/cit/x.png");
let mut sprites = Sprites::default();
let block = sprites.insert(&root, "a:block/x").unwrap();
let item = sprites.insert(&root, "a:item/x").unwrap();
let cit = sprites
.insert(&root, "horizon_cit:minecraft/optifine/cit/x")
.unwrap();
fs::remove_dir_all(&root).unwrap();
// The same pixels share a layer only with the same mipmap settings: the CIT texture's
// are the item's.
assert_ne!(block, item);
assert_eq!(cit, item);
}
#[test]
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
let png = |path: &str, side: u32, seed: u8| {
let file = root.join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..side * side)
.flat_map(|i| {
[
seed,
i as u8,
(i >> 8) as u8,
if i % 3 == 0 { 0 } else { 255 },
]
})
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
for i in 0..40u8 {
png(
&format!("assets/minecraft/textures/block/b{i}.png"),
16 << (i % 3),
i,
);
}
// Two textures alike share a layer; a tile outside `textures/`, a broken file and a
// missing one take the paths they take without prefetching.
png("assets/minecraft/textures/block/same.png", 16, 3);
png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77);
fs::write(
root.join("assets/minecraft/textures/block/broken.png"),
b"no png",
)
.unwrap();
let names: Vec<String> = (0..40)
.map(|i| format!("minecraft:block/b{}", (i * 7) % 40))
.chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from))
.chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from))
.collect();
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
let load = |prefetch: bool| {
let mut sprites = Sprites::default();
if prefetch {
sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile]));
}
let mut layers: Vec<u32> = names
.iter()
.map(|name| sprites.insert(&root, name).unwrap())
.collect();
layers.push(sprites.insert_file(&root, tile).unwrap());
sprites.finish();
(
layers,
sprites.layers,
sprites.pixels,
sprites.translucent,
sprites.size,
)
};
let alone = load(false);
assert_eq!(load(true), alone);
// The textures alike and the broken one's missing texture: fewer layers than names.
assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn identical_still_textures_share_a_layer() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
+110 -3
View File
@@ -1288,13 +1288,28 @@ impl Streaming {
}
/// Whether any chunk is queued or on its way from generation to a mesh.
pub fn busy(&self) -> bool {
self.chunks_in_flight() || !self.meshing.is_empty() || self.pending() != 0
}
/// Whether the near terrain is still coming in: chunks generating,
/// decorating, finishing or being lit (or their neighbours' light views),
/// or sections meshing for the first time. Each of those is counted from
/// when its job is queued, so queued jobs need no separate count.
/// Remeshing sections already drawn (block, light and fluid updates) is
/// not loading: it kept small machines' far terrain held back
/// (`Lod::set_near_loading`) for as long as anything nearby changed.
pub fn terrain_loading(&self) -> bool {
self.chunks_in_flight()
|| self
.meshing
.keys()
.any(|section| !self.compile_started.contains_key(section))
}
fn chunks_in_flight(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
&& self.pending() == 0)
&& self.lighting.is_empty())
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
@@ -2073,6 +2088,59 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
}
/// How long a view's columns are waited for (`ViewArrival`): a server may never
/// fill it.
const VIEW_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
/// Whether the columns of a view (centre, radius) have all arrived, as a client
/// sees them come: a server forgets the old view's columns, then sends the new
/// ones nearest first, so the view's outermost ones come last. Single player
/// joins its integrated server the same way, so `Streaming::busy` cannot see
/// what is not sent yet.
#[derive(Default)]
pub struct ViewArrival {
view: ((i32, i32), i32),
since: Option<std::time::Instant>,
arrived: bool,
}
impl ViewArrival {
/// Whether columns of `view` have yet to arrive (`has`): its outermost ones,
/// at both ends of each of its rows and columns (`in_view_distance`). Once
/// they have, false until the view changes; false too once it is no longer
/// `recent`, and for a radius below 0 (no view).
pub fn arriving(
&mut self,
view: ((i32, i32), i32),
now: std::time::Instant,
has: impl Fn((i32, i32)) -> bool,
) -> bool {
if self.since.is_none() || view != self.view {
(self.view, self.since, self.arrived) = (view, Some(now), false);
}
let ((x, z), r) = view;
if self.arrived || r < 0 || !self.recent(now) {
return false;
}
self.arrived = (-r..=r).all(|d| {
// How far row (and column) `d` reaches: `in_view_distance` solved for it.
let gap = i64::from((d.abs() - 1).max(0));
let room = i64::from(r).pow(2) - gap * gap;
room <= 0 || {
let e = (room - 1).isqrt() as i32 + 1;
// Both ends of row `d`, and of column `d`.
[-e, e]
.into_iter()
.all(|e| has((x + d, z + e)) && has((x + e, z + d)))
}
});
!self.arrived
}
/// Whether the view `arriving` was last given changed less than `VIEW_WAIT`
/// ago: until then sections meshed are its terrain coming in, later an edit's.
pub fn recent(&self, now: std::time::Instant) -> bool {
self.since
.is_some_and(|since| now.saturating_duration_since(since) < VIEW_WAIT)
}
}
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
@@ -2801,6 +2869,45 @@ mod tests {
}
}
/// A view has arrived once the columns at both ends of each of its rows and
/// columns are in: exactly those with a neighbour outside it, at every radius.
/// Then it stays arrived until it changes; a view is `recent`, and waited for,
/// `VIEW_WAIT` at most, and one of no radius not at all.
#[test]
fn a_view_has_arrived_once_its_outermost_columns_are_in() {
let start = std::time::Instant::now();
let center = (3, -4);
for r in 0..=16 {
let view: FxHashSet<(i32, i32)> = (-r - 1..=r + 1)
.flat_map(|x| (-r - 1..=r + 1).map(move |z| (center.0 + x, center.1 + z)))
.filter(|&(x, z)| in_view_distance(center, r, x, z))
.collect();
let arriving = |has: &dyn Fn((i32, i32)) -> bool| {
ViewArrival::default().arriving((center, r), start, has)
};
assert!(!arriving(&|c| view.contains(&c)), "radius {r}");
for &(x, z) in &view {
let edge = [(x + 1, z), (x - 1, z), (x, z + 1), (x, z - 1)]
.iter()
.any(|c| !view.contains(c));
let missing = arriving(&|c| c != (x, z) && view.contains(&c));
assert_eq!(missing, edge, "radius {r}, column ({x}, {z})");
}
}
let mut arrival = ViewArrival::default();
let (none, all) = (|_| false, |_| true);
assert!(arrival.arriving(((0, 0), 2), start, none));
assert!(!arrival.arriving(((0, 0), 2), start, all));
assert!(!arrival.arriving(((0, 0), 2), start, none));
// Moved: waited for again, until `VIEW_WAIT` after the move.
let (late, ms) = (start + VIEW_WAIT, std::time::Duration::from_millis(1));
assert!(arrival.arriving(((1, 0), 2), start, none));
assert!(arrival.arriving(((1, 0), 2), late - ms, none));
assert!(arrival.recent(late - ms) && !arrival.recent(late));
assert!(!arrival.arriving(((1, 0), 2), late, none));
assert!(!arrival.arriving(((1, 0), -1), start, none));
}
struct GenerationFixture {
world: World,
center: (i32, i32),
@@ -241,6 +241,7 @@ impl Session {
pub fn title_shown(&mut self) {
if !self.title_measured {
self.title_measured = true;
crate::spans::startup::mark("title.shown");
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
self.to_title_ms = if ui_ms != u32::MAX {
Some(ui_ms)
@@ -252,6 +253,9 @@ impl Session {
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
crate::spans::startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
@@ -262,10 +266,11 @@ impl Session {
/// A frame drawn while playing (not in menus or loading).
pub fn frame(&mut self, ms: f32) {
if let Some(opening) = self.opening.take()
&& self.to_world_ms.is_none()
{
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
}
}
self.frames.record(ms);
self.played_ms += ms;
+42 -19
View File
@@ -24,9 +24,9 @@ struct Globals {
outline_params: vec4<f32>,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_lights: array<vec4<f32>, 32>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_count: vec4<u32>,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
@@ -34,6 +34,9 @@ struct Globals {
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
// in texels (vanilla's TextureSize).
filtering: vec4<f32>,
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -249,8 +252,8 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
var rgb = vec3(0.0);
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
let at = globals.colored_lights[i * 2u];
let color = globals.colored_lights[i * 2u + 1u];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
@@ -308,10 +311,10 @@ fn fade_code(w: f32) -> u32 {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid terrain draws in two passes from the same draws: first the quads with no
// cutout that are not fading, through `fs_opaque`, which never discards, so a tiled
// GPU (Apple's) keeps hiding the pixels behind them; then the rest through
// `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// Solid terrain draws in two passes, each over its own part of every mesh's quads
// (`mesher::Part`): first the quads with no cutout that are not fading, through
// `fs_opaque`, which never discards, so a tiled GPU (Apple's) keeps hiding the pixels
// behind them; then the rest through `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
@@ -367,9 +370,15 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.uv = uv;
out.layer = layer;
out.emissive_layer = 0xffffffffu;
for (var i = 0u; i < min(globals.emissive_count.x, 32u); i++) {
if globals.emissive_layers[i].x == (layer & 0x7fffu) {
out.emissive_layer = globals.emissive_layers[i].y;
// The pairs are sorted by base layer: stop at the first past this one.
let sprite_layer = layer & 0x7fffu;
for (var i = 0u; i < min(globals.emissive_count.x, 128u); i++) {
let pair = globals.emissive_layers[i];
if pair.x == sprite_layer {
out.emissive_layer = pair.y;
break;
}
if pair.x > sprite_layer {
break;
}
}
@@ -404,6 +413,14 @@ fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
}
// HDR output's mark (hdr.wgsl): the frame's alpha below 0.75, a band of 0.175 per
// tier (1 low .. 4 very bright, from 0.525 down to 0) and in it how much the pixel
// lifts, from its own brightness `y` before lighting through the tier's window.
fn hdr_mark(tier: u32, y: f32) -> f32 {
let t = clamp(tier, 1u, 4u) - 1u;
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
return solid_color(in);
}
@@ -434,23 +451,29 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel may
// shine, and at which tier (mesher::hdr_tier): 0.525 at the lowest, 0 at the
// brightest. Only pixels as bright as their sprite's threshold may: its
// bright parts, or all of it. Nothing else reads the frame's alpha.
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel
// shines, at which tier (mesher::hdr_tier) and how much (hdr_mark), by the
// texel's own brightness, so its light level and face shading don't change
// it. Only pixels as bright as their sprite's threshold may: its bright
// parts, or all of it. Nothing else reads the frame's alpha.
var emissive = 1.0;
if in.emission > 0u {
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
if brightness(texel.rgb) >= select(t.r, t.g, whole) {
emissive = 0.7 * f32(4u - min(tier, 4u)) / 4.0;
let y = brightness(texel.rgb);
if y >= select(t.r, t.g, whole) {
emissive = hdr_mark(tier, y);
}
}
if in.emissive_layer != 0xffffffffu {
let mask = sprite(in.uv, in.emissive_layer);
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
// The high bit: an OptiFine overlay, drawn cutout (opaque where alpha >= 0.1).
var mask = sprite(in.uv, in.emissive_layer & 0x7fffffffu);
if (in.emissive_layer & 0x80000000u) != 0u {
mask.a = select(0.0, 1.0, mask.a >= 0.1);
}
return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, hdr_mark(3u, 1.0), mask.a)), in.spherical, in.cylindrical);
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
+211 -5
View File
@@ -321,7 +321,9 @@ fn centered_left(x: f32, w: f32, width: f32) -> f32 {
x + (w / 2.0).floor() - (width / 2.0).floor()
}
/// Centered text with a shadow, scrolled when wider than `w` (AbstractWidget.renderScrollingString).
/// Centered text with a shadow, for titles and lines of text, which vanilla
/// never scrolls: one wider than `w` keeps the characters that fit. Widget
/// labels go through [`scrolling_text`] instead, as vanilla's do.
pub fn centered_text(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -336,7 +338,8 @@ pub fn centered_text(
if width <= w {
batch.text_styled(atlas, &text, centered_left(x, w, width), y, color, true);
} else {
// Clip by characters (vanilla scrolls; we show the part that fits).
// Clip by characters: vanilla clips nothing here, but a title is as
// wide as the window.
let mut shown = Text::empty();
let mut used = 0.0;
for (i, (c, s)) in text.chars().enumerate() {
@@ -349,6 +352,94 @@ pub fn centered_text(
batch.text_styled(atlas, &shown, x + 1.0, y, color, true);
}
}
/// `ActiveTextCollector.defaultScrollingHelper`'s slide: a label `overflow`
/// pixels too wide moves from its end back to its start and out again, one
/// sine period every `max(overflow * 0.5, 3)` seconds of `Util.getMillis`.
fn scroll_offset(overflow: f32, now_ms: i64) -> f64 {
let period = (f64::from(overflow) * 0.5).max(3.0);
let seconds = now_ms as f64 / 1000.0;
let phase = (std::f64::consts::TAU * seconds / period).cos();
let pos = (std::f64::consts::FRAC_PI_2 * phase).sin() / 2.0 + 0.5;
f64::from(overflow) * pos
}
/// Where an overflowing label's left edge is drawn: `left - (int) pos`.
fn scrolled_left(left: f32, overflow: f32, now_ms: i64) -> f32 {
left - scroll_offset(overflow, now_ms) as i32 as f32
}
/// A widget's label as `AbstractWidget.extractScrollingStringOverContents`
/// draws it, at `now_ms`: centred between `left` and `right` when it fits,
/// else drawn from the left and slid (`defaultScrollingHelper`) inside the
/// widget's box. `y` is the label's top line.
#[allow(clippy::too_many_arguments)]
pub fn scrolling_text_at(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
left: f32,
right: f32,
top: f32,
bottom: f32,
y: f32,
now_ms: i64,
color: u32,
) {
let text = text.resolved();
let width = atlas.font.width_text(&text);
let room = right - left;
if width <= room {
batch.text_styled(
atlas,
&text,
centered_left(left, room, width),
y,
color,
true,
);
return;
}
// Scissored to the widget: what does not fit is read as the label
// slides, never dropped.
let start = batch.vertices.len();
batch.text_styled(
atlas,
&text,
scrolled_left(left, width - room, now_ms),
y,
color,
true,
);
batch.clip_since(start, left, top, right, bottom);
}
/// [`scrolling_text_at`] at `Util.getMillis`.
#[allow(clippy::too_many_arguments)]
pub fn scrolling_text(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
left: f32,
right: f32,
top: f32,
bottom: f32,
y: f32,
color: u32,
) {
scrolling_text_at(
batch,
atlas,
text,
left,
right,
top,
bottom,
y,
crate::hud_overlays::now_ms(),
color,
);
}
/// Button (AbstractButton): nine-slice sprite plus the centred label.
pub fn button(
batch: &mut HudBatch,
@@ -397,13 +488,15 @@ pub fn button_as(
} else {
rgba(160, 160, 160, 255)
};
centered_text(
scrolling_text(
batch,
atlas,
label,
x + 2.0,
x + w - 2.0,
y,
y + h,
y + ((h - 8.0) / 2.0).floor(),
w - 4.0,
color,
);
}
@@ -471,7 +564,17 @@ pub fn slider_ext(
} else {
rgba(160, 160, 160, 255)
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
scrolling_text(
batch,
atlas,
label,
x + 2.0,
x + w - 2.0,
y,
y + 20.0,
y + 6.0,
color,
);
}
#[cfg(test)]
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
@@ -1436,6 +1539,109 @@ mod tests {
assert_eq!(centered_left(10.0, 196.0, 9.0), 104.0);
assert_eq!(centered_left(2.0, 196.0, 7.0), 97.0);
}
/// `defaultScrollingHelper`: one period every `max(overflow * 0.5, 3)`
/// seconds, a full sine: the label starts with its end showing, shows its
/// start half a period later, and never slides past either end.
#[test]
fn an_overflowing_label_slides_from_its_end_to_its_start_and_back() {
let overflow = 40.0;
// max(40 * 0.5, 3) = 20 s.
assert!((scroll_offset(overflow, 0) - 40.0).abs() < 1.0e-9);
assert!(scroll_offset(overflow, 10_000).abs() < 1.0e-9);
assert!((scroll_offset(overflow, 20_000) - 40.0).abs() < 1.0e-9);
// A label one pixel too wide waits the floor of three seconds.
assert!(scroll_offset(1.0, 1_500).abs() < 1.0e-9);
assert!((scroll_offset(1.0, 0) - 1.0).abs() < 1.0e-9);
for ms in 0..40_000 {
let offset = scroll_offset(overflow, ms);
assert!((0.0..=40.0).contains(&offset), "{ms} ms: {offset}");
}
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `defaultScrollingHelper` draws an overflowing label from `left` less the
/// integer part of the slide, so it shows its end first and its start half
/// a period later, and it moves a whole pixel at a time.
#[test]
fn an_overflowing_label_is_drawn_left_less_the_integer_slide() {
// max(40 * 0.5, 3) = 20 s: at 0 s the whole overflow is slid, at 10 s none.
assert_eq!(scrolled_left(10.0, 40.0, 0), -30.0);
assert_eq!(scrolled_left(10.0, 40.0, 10_000), 10.0);
// A fractional slide is cut, not rounded.
let offset = scroll_offset(40.0, 1_000) as i32 as f32;
assert!(offset > 0.0);
assert_eq!(scrolled_left(10.0, 40.0, 1_000), 10.0 - offset);
}
/// The label of a widget it does not fit is clipped to the widget's box
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
/// label ran over the button beside it).
#[test]
fn a_label_wider_than_its_widget_stays_inside_it() {
let atlas = atlas();
let label = Text::literal("Graphics API: Prefer Vulkan (Experimental)");
assert!(atlas.font.width_text(&label.resolved()) > 96.0);
let mut batch = HudBatch::new([640, 480]);
scrolling_text_at(
&mut batch,
&atlas,
&label,
10.0,
106.0,
20.0,
40.0,
26.0,
0,
u32::MAX,
);
assert!(!batch.vertices.is_empty());
for v in &batch.vertices {
assert!(
(10.0..=106.0).contains(&v.pos[0]) && (20.0..=40.0).contains(&v.pos[1]),
"{:?} outside the widget",
v.pos
);
}
// A label that fits is centred and not cut: every glyph is drawn.
let mut batch = HudBatch::new([640, 480]);
let short = Text::literal("Done");
scrolling_text_at(
&mut batch,
&atlas,
&short,
10.0,
106.0,
20.0,
40.0,
26.0,
0,
u32::MAX,
);
let width = atlas.font.width_text(&short.resolved());
let left = centered_left(10.0, 96.0, width);
let lo = batch
.vertices
.iter()
.map(|v| v.pos[0])
.fold(f32::INFINITY, f32::min);
let hi = batch
.vertices
.iter()
.map(|v| v.pos[0])
.fold(f32::NEG_INFINITY, f32::max);
assert!(
(lo - left).abs() < 0.001,
"centred at {left}, drawn from {lo}"
);
assert!(
hi - lo >= width - 0.001,
"the whole label is drawn: {lo}..{hi}, {width} wide"
);
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
+16 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -104,20 +109,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -99,6 +99,7 @@
"UI Brightness": "UI Brightness",
"Battery Mode": "Battery Mode",
"Graphics API": "Graphics API",
"Translucent Batching": "Translucent Batching",
"Performance Overlay": "Performance Overlay",
"Full Detail Distance": "Full Detail Distance",
"On": "On",
@@ -146,6 +147,7 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"%s chunks": "%s chunks",
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
@@ -205,6 +207,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS cap (30 in menus or unfocused), half the workers: less power.",
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
@@ -209,5 +209,102 @@
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…"
"horizon.shader_options.loading": "正在读取光影包的选项…",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
"horizon.pages.open.split_setup": "分屏…",
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
"horizon.extras.header.lighting": "光照",
"horizon.extras.header.third_person": "第三人称",
"horizon.extras.header.particles": "粒子",
"horizon.extras.header.inventory": "物品栏",
"horizon.extras.header.chat": "聊天",
"horizon.extras.header.multiplayer": "多人游戏",
"horizon.extras.header.sound": "声音",
"horizon.extras.fullbright": "全亮",
"horizon.extras.dynamic_lights": "动态光源",
"horizon.extras.own_name_tag": "自己的名字标签",
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
"horizon.extras.auto_reconnect": "自动重连",
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
"horizon.extras.mouse_tweaks": "鼠标调整控制",
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
"horizon.extras.sort": "排序按钮",
"horizon.extras.sort_key": "排序快捷键",
"horizon.extras.no_ai": "关闭 AI",
"horizon.extras.agent_control": "代理控制",
"horizon.extras.discord_presence": "Discord 动态状态",
"horizon.extras.splitscreen": "分屏",
"horizon.extras.chat_animation": "平滑聊天",
"horizon.extras.chat_indicator": "消息指示条",
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
"Calibrate...": "校准…",
"Calibrate Anyway...": "仍要校准…",
"Use Automatic Setup": "使用自动设置",
"Shortcut: %s": "快捷键:%s",
"Next": "下一步",
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
"horizon.splitscreen.profiles.forget": "忘记",
"horizon.splitscreen.profiles.create": "创建",
"horizon.splitscreen.profiles.you": " - 你",
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
"horizon.splitscreen.profiles.previous": "上一个",
"horizon.splitscreen.profiles.next": "下一个",
"horizon.splitscreen.profiles.new_button": "新建档案…",
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
"horizon.splitscreen.profiles.new_title": "新建档案",
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
"horizon.splitscreen.profiles.switch_title": "切换档案",
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
"horizon.splitscreen.profiles.move": "移动游玩位置",
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
"horizon.friends.title": "与好友一起玩",
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
"horizon.friends.copied": "已复制",
"horizon.friends.copy_code": "复制代码",
"horizon.friends.stop_sharing": "停止共享",
"horizon.friends.none_yet": "还没有好友加入。",
"horizon.friends.here_now": "当前在线:%s",
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
"horizon.friends.start_sharing": "开始共享",
"horizon.friends.troubleshooting": "疑难解答…",
"horizon.friends.stopped": "已停止共享邀请码。",
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
"horizon.friends.share_failed": "无法共享世界:%s",
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "空格",
"horizon.osk.delete": "删除",
"horizon.osk.type": "输入",
"horizon.osk.symbols": "符号",
"horizon.osk.close": "关闭",
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
}
@@ -12,6 +12,7 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
Loaded 100 of 527 files, more files were not shown because too many files have changed in this diff. Show more