Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 d6424564e6 Dedicated server: item renderer without the client item definitions
A dedicated server's ItemRenderer kept what only drawing reads: the
~1,500 client item definitions (items/**/*.json), the special
renderers' entity models and the grass colormap, 1.4 MB of the 7.3 MB.
ItemRenderer::load_for_server keeps the prototypes (the stacks' default
components, which gameplay reads) and the item ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
Ludovic MarieandClaude Opus 5.5 0871e93d49 Commands: cache what the registries take from the export, not the export
command_export kept the parsed command-registries.json (4.2 MB of
serde_json Values) for the whole run, though the registries take only
part of it. Cache Registries::from_export, the fields fill_from_export
fills (2.2 MB), and fill from it with fill_from_exported. A dedicated
server opens its world once: it keeps nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
Ludovic MarieandClaude Opus 5.5 cdb64fbc9b LAN server: keep recipe files as compact JSON text, parse per book entry
ServerAdvancements kept all ~2,000 recipe files parsed (4.6 MB of
serde_json Values) for the server's whole run, only to build recipe book
entries when a player joins or unlocks a recipe. Keep each file as its
compact JSON text (0.57 MB) and parse the one an entry needs: parsing
every recipe takes 1.2 ms, the worst case of a full book on join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
707 changed files with 6011 additions and 68048 deletions

No files matched your search

-5
View File
@@ -4,8 +4,3 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
+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 -p horizon-client-lib --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 --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
@@ -1,61 +0,0 @@
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"
+9 -17
View File
@@ -30,7 +30,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
@@ -40,8 +39,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `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-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-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`...) |
@@ -86,8 +84,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client --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 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
@@ -104,11 +102,8 @@ 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/`.
- 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.
- 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.
- 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):
@@ -133,11 +128,8 @@ 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:` = 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.
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>`.
| Subsystem | Files |
|---|---|
@@ -159,7 +151,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| 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`, `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` |
| 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` |
| 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` |
@@ -168,7 +160,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
- `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 stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- 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
-61
View File
@@ -1314,7 +1314,6 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1375,62 +1374,6 @@ 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"
@@ -1607,7 +1550,6 @@ dependencies = [
name = "horizon-protocol"
version = "0.1.0"
dependencies = [
"aes",
"flate2",
"getrandom 0.3.4",
"horizon-core",
@@ -5120,9 +5062,6 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
-1
View File
@@ -8,7 +8,6 @@ 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",
-24
View File
@@ -91,27 +91,3 @@ licensed under the MIT license:
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## FusionExamples
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
texture templates (`templates/template <layout>.png`) of
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
licensed under the MIT license:
> Copyright (c) 2024 Martijn van den Brand
>
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
> and associated documentation files (the "Software"), to deal in the Software without
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
> Software is furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all copies or
> substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,758 +0,0 @@
{
"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"
}
@@ -1,758 +0,0 @@
{
"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"
}
@@ -1,758 +0,0 @@
{
"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"
}
@@ -1,68 +0,0 @@
# 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).
@@ -1,707 +0,0 @@
{
"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"
}
@@ -1,707 +0,0 @@
{
"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"
}
@@ -1,707 +0,0 @@
{
"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"
}
@@ -1,723 +0,0 @@
{
"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"
}
@@ -1,723 +0,0 @@
{
"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"
}
@@ -1,723 +0,0 @@
{
"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"
}
+2 -95
View File
@@ -7,7 +7,7 @@ use crate::{
json::Result,
math::{Mat4, quaternion_xyz},
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
resolve::{ModelLibrary, ResolvedCuboidModel},
resolve::ModelLibrary,
};
use std::{collections::BTreeSet, f32::consts::PI};
@@ -110,15 +110,6 @@ 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) {
@@ -152,7 +143,7 @@ pub fn bake_resolved(
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);
}
@@ -307,87 +298,3 @@ 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]);
}
}
-275
View File
@@ -102,74 +102,10 @@ 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)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
pub fn resolves(&self, name: &str) -> bool {
let Ok(mut name) = identifier(name) else {
return false;
};
let mut visited = BTreeSet::new();
loop {
let Some(model) = self.models.get(&name) else {
return false;
};
if !visited.insert(name.clone()) {
return false;
}
match &model.parent {
Some(parent) => name = parent.clone(),
None => return true,
}
}
}
/// `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();
@@ -220,9 +156,6 @@ 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() {
@@ -341,195 +274,6 @@ 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();
@@ -549,23 +293,4 @@ mod tests {
"minecraft:missingno"
);
}
#[test]
fn a_model_resolves_only_with_its_whole_parent_chain() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
("item/orphan", r#"{"parent":"item/gone"}"#),
("item/a", r#"{"parent":"item/b"}"#),
("item/b", r#"{"parent":"item/a"}"#),
]);
assert!(library.resolves("item/stick"));
assert!(library.resolves("minecraft:item/stick"));
assert!(!library.resolves("item/absent"));
assert!(!library.resolves("item/orphan"));
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
}
+5 -3
View File
@@ -202,8 +202,10 @@ 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; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
// 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)
}
#[cfg(target_os = "linux")]
@@ -225,7 +227,7 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
#[cfg(target_os = "windows")]
fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
output("cmd", &["/C", "ver"])
}
#[cfg(target_os = "windows")]
+2 -106
View File
@@ -3,7 +3,6 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -35,15 +34,6 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -264,50 +254,8 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -319,19 +267,13 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
.unwrap_or_else(|| key.to_owned())
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
.unwrap_or_else(|| fallback.to_owned())
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -408,52 +350,6 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -51,10 +51,6 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
@@ -483,21 +483,6 @@ 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)]
@@ -534,29 +519,6 @@ 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,20 +207,6 @@ 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
@@ -1,221 +0,0 @@
[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", "glsl-out", "msl-out", "spv-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -1,436 +0,0 @@
//! 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,8 +93,6 @@ 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>,
@@ -244,7 +242,6 @@ 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.
@@ -273,11 +270,6 @@ 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();
@@ -297,7 +289,6 @@ 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,26 +208,10 @@ pub fn list_textures(root: &Path) -> Vec<String> {
out.sort();
out
}
/// 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.
/// The file of a texture identifier.
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));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
}
/// `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, ModEnergyBuffer, ModFluidTanks};
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -46,7 +46,6 @@ pub enum Ticking {
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
@@ -77,41 +76,6 @@ 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
/// (`take`) stays listed until `put` or `remove`.
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
/// The positions of the block entities that draw a bounding box (structure and test
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
fn shows_box(entity: &BlockEntity) -> bool {
matches!(
entity,
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
)
}
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
(x >> 4, z >> 4)
}
/// Takes `pos` out of its chunk's mod block entity set.
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
let chunk = chunk_of(pos);
if let Some(set) = mods.get_mut(&chunk) {
set.remove(&pos);
if set.is_empty() {
mods.remove(&chunk);
}
}
}
impl std::ops::Deref for BlockEntities {
@@ -208,13 +172,6 @@ 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;
@@ -233,65 +190,6 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
!self.mods.is_empty()
}
/// The structure and test instance blocks (a frame draws their boxes), in no order.
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
self.boxes
.iter()
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
}
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
if entity.is_some_and(shows_box) {
self.boxes.insert(pos);
} else {
self.boxes.remove(&pos);
}
}
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
/// far ones are skipped whole; the caller checks each distance).
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
let far = move |c: i32, at: f64| {
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
at < lo - radius || at > hi + radius
};
self.mods
.iter()
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.map.get_mut(pos)
}
@@ -303,33 +201,11 @@ impl BlockEntities {
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
}
if new_box {
self.boxes.insert(pos);
} else if old.as_ref().is_some_and(shows_box) {
self.boxes.remove(&pos);
}
if new_is_mod {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, pos);
}
old
}
/// The block entity at `pos`, made by `make` when there is none.
@@ -338,29 +214,15 @@ impl BlockEntities {
pos: Pos,
make: impl FnOnce() -> BlockEntity,
) -> &mut BlockEntity {
let absent = !self.map.contains_key(&pos);
if absent {
if !self.map.contains_key(&pos) {
self.add(pos);
}
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
if absent && shows_box(entity) {
self.boxes.insert(pos);
}
entity
self.map.entry(pos).or_insert_with(make)
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
removed
self.map.remove(pos)
}
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
@@ -374,10 +236,6 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -388,30 +246,18 @@ impl BlockEntities {
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
}
kept
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -428,18 +274,9 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
if old.is_some() {
if self.map.insert(p, e).is_some() {
self.replaced.insert(p);
}
if new_is_mod {
self.mods.entry(chunk_of(p)).or_default().insert(p);
} else if matches!(old, Some(BlockEntity::Mod(_))) {
forget_mod(&mut self.mods, p);
}
}
for p in order {
self.add(p);
@@ -532,84 +369,6 @@ mod tests {
.collect()
}
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
found.sort();
found
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
b.insert((1, 64, 1), modded());
b.insert((200, 64, 1), modded());
b.insert((2, 64, 2), hopper());
b.get_or_insert_with((3, 64, 3), modded);
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
// Only mods, and only chunks within reach.
assert_eq!(
near(&b, 0.0, 0.0, 64.0),
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
);
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
// Taken out for a tick, still listed; removed or replaced, gone.
let taken = b.take(&(1, 64, 1)).unwrap();
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
b.put((1, 64, 1), taken);
b.remove(&(3, 64, 3));
b.insert((4, 64, 4), hopper());
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
b.retain(|p, _| p.0 != 1);
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
@@ -694,43 +453,4 @@ mod tests {
b.state_changed(p, 2, 1);
assert_eq!(order(&mut b, &s), [q, p]);
}
#[test]
fn boxes_list_exactly_the_structure_and_test_blocks() {
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
let listed = |b: &BlockEntities| {
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
v.sort();
v
};
let mut b = level();
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
b.insert(p, test_block());
b.insert(q, hopper());
assert_eq!(listed(&b), [p]);
// Replaced by another kind, and back.
b.insert(p, hopper());
assert_eq!(listed(&b), []);
b.insert(q, test_block());
b.get_or_insert_with(r, test_block);
assert_eq!(listed(&b), [q, r]);
// Taken out: not shown until put back; put back as another kind: gone.
let taken = b.take(&q).unwrap();
assert_eq!(listed(&b), [r]);
b.put(q, taken);
assert_eq!(listed(&b), [q, r]);
let taken = b.take(&r).unwrap();
drop(taken);
b.put(r, hopper());
assert_eq!(listed(&b), [q]);
b.remove(&q);
assert_eq!(listed(&b), []);
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
assert_eq!(listed(&b), [p]);
b.retain(|pos, _| *pos != p);
assert_eq!(listed(&b), []);
b.insert(r, test_block());
b.clear();
assert!(b.boxes.is_empty());
}
}
@@ -1,630 +0,0 @@
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
//! atlas has it.
//!
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
//! block models here, so their files are not read; copper golem statues have no CEM name.
use crate::block_entity_render::ModelPart;
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
/// names for its vanilla parts.
struct Target {
key: &'static str,
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
/// apart shares (a trapped chest's is the chest's).
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
}
const CHEST: &[(&str, &str)] = &[
("root", "root"),
("base", "bottom"),
("lid", "lid"),
("knob", "lock"),
];
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
const PIGLIN: &[(&str, &str)] = &[
("root", "root"),
("head", "head"),
("left_ear", "left_ear"),
("right_ear", "right_ear"),
];
const BELL: &[(&str, &str)] = &[
("root", "root"),
("body", "bell_body"),
("base", "bell_base"),
];
const BOOK: &[(&str, &str)] = &[
("root", "root"),
("cover_right", "right_lid"),
("cover_left", "left_lid"),
("pages_right", "right_pages"),
("pages_left", "left_pages"),
("flipping_page_right", "flip_page1"),
("flipping_page_left", "flip_page2"),
("book_spine", "seam"),
];
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
const POT_SIDES: &[(&str, &str)] = &[
("front", "front"),
("back", "back"),
("left", "left"),
("right", "right"),
];
const fn model(
key: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base: key,
names,
parts,
}
}
const fn copy(
key: &'static str,
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base,
names,
parts,
}
}
/// In order: a copy comes after its base.
const TARGETS: &[Target] = &[
model("chest", &["chest"], CHEST),
model("double_chest_left", &["chest_left"], CHEST),
model("double_chest_right", &["chest_right"], CHEST),
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
copy(
"trapped_chest_left",
"double_chest_left",
&["trapped_chest_left"],
CHEST,
),
copy(
"trapped_chest_right",
"double_chest_right",
&["trapped_chest_right"],
CHEST,
),
copy("ender_chest", "chest", &["ender_chest"], CHEST),
model("shulker_box", &["shulker_box"], SHULKER),
model("standing_banner", &["banner_base", "banner"], BANNER),
model("wall_banner", &["banner_base", "banner"], BANNER),
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
model("skeleton_skull", &["head_skeleton"], HEAD),
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
model("zombie_head", &["head_zombie"], HEAD),
model("creeper_head", &["head_creeper"], HEAD),
model("dragon_skull", &["head_dragon"], DRAGON),
model("piglin_head", &["head_piglin"], PIGLIN),
model("player_head", &["head_player"], HEAD),
model("bell", &["bell"], BELL),
model("book", &["enchanting_book", "book"], BOOK),
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
model("conduit_shell", &["conduit"], &[("base", "shell")]),
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
];
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
pub type Reshaped = (
HashMap<&'static str, ModelPart>,
HashMap<&'static str, String>,
);
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
/// a `.jem` there.
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
let (reshaped, unused) = reshape_all(root, vanilla)?;
for (path, part) in unused {
log::warn!("Horizon: custom entity model {path}: no part {part}");
}
Some(reshaped)
}
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
fn reshape_all(
root: &Path,
vanilla: &HashMap<&'static str, ModelPart>,
) -> Option<(Reshaped, Vec<(String, String)>)> {
let files = jem_files(root);
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
if !TARGETS.iter().any(named) {
return None;
}
let mut models = vanilla.clone();
let mut textures = HashMap::new();
let mut reshaped = HashSet::new();
// Each file parsed once; which of its entries found a part in some model.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
for t in TARGETS {
let Some(model) = vanilla.get(t.base) else {
continue;
};
let found = t
.names
.iter()
.flat_map(|name| files.get(*name).into_iter().flatten())
.map(String::as_str)
.find(|&path| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some(path) = found else {
// A copy of a reshaped base that has no file of its own stays vanilla.
if t.key != t.base && reshaped.contains(t.base) {
models.insert(t.key, model.clone());
}
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut out = model.clone();
let hits = apply(&mut out, jem, t.parts);
// A file shared by several models (the conduit's, the pot's) paints only the models
// it reshapes: vanilla geometry keeps its own texture's layout.
let reshapes = hits.iter().any(|hit| *hit);
let marks = used
.entry(path)
.or_insert_with(|| vec![false; jem.parts.len()]);
for (mark, hit) in marks.iter_mut().zip(hits) {
*mark |= hit;
}
models.insert(t.key, out);
reshaped.insert(t.key);
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
textures.insert(t.key, texture);
}
}
let mut unused = Vec::new();
for (path, marks) in &used {
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
unused.push(((*path).to_owned(), p.part.clone()));
}
}
Some(((models, textures), unused))
}
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
/// `cem#<n>`. Which entries found their part.
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
let mut count = 0;
let mut hits = Vec::with_capacity(jem.parts.len());
for p in &jem.parts {
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
let target = match mapped {
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
_ => p.part.as_str(),
};
let Some(node) = find_mut(model, target) else {
hits.push(false);
continue;
};
if !p.attach {
node.polygons.clear();
}
let (name, mut bone) = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
node.children.push((name, bone));
hits.push(true);
}
hits
}
/// The model itself for `root`, else its first part named `name`, depth first.
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
part.children.iter_mut().find_map(|(n, c)| {
if n.as_str() == name {
Some(c)
} else {
child(c, name)
}
})
}
if name == "root" {
return Some(model);
}
child(model, name)
}
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
/// change by name reaches it.
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
let out = ModelPart {
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
};
(name, out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::{collections::BTreeSet, fs};
/// A part with one face and `children`.
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[[0.0; 5]; 4]],
children: children
.into_iter()
.map(|(n, c)| (n.to_owned(), c))
.collect(),
}
}
pub(crate) fn chest() -> ModelPart {
let mut root = plane(vec![
("bottom", plane(vec![])),
("lid", plane(vec![])),
("lock", plane(vec![])),
]);
root.polygons.clear();
root
}
pub(crate) const BOX: &str =
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// A `.jem` replacing each of `parts` with one box.
fn jem(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
/// deep, so a part that took another entry's box shows.
fn jem_sized(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.enumerate()
.map(|(i, p)| {
let depth = i + 1;
format!(
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
)
})
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// How deep (z) the custom box under `model`'s part `name` is.
fn depth(model: &ModelPart, name: &str) -> f32 {
let part = model.child(name).unwrap();
let zs = part
.children
.iter()
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
hi - lo
}
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
/// child of a box's six faces.
fn replaced(model: &ModelPart, name: &str) -> bool {
let part = model.child(name).unwrap();
match part.children.as_slice() {
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
_ => false,
}
}
#[test]
fn every_optifine_part_name_lands_on_its_vanilla_part() {
// File, OptiFine's part, the model it reshapes, vanilla's part there.
let cases = [
("chest", "base", "chest", "bottom"),
("chest", "lid", "chest", "lid"),
("chest", "knob", "chest", "lock"),
("chest_left", "base", "double_chest_left", "bottom"),
("chest_left", "lid", "double_chest_left", "lid"),
("chest_left", "knob", "double_chest_left", "lock"),
("chest_right", "base", "double_chest_right", "bottom"),
("chest_right", "lid", "double_chest_right", "lid"),
("chest_right", "knob", "double_chest_right", "lock"),
("trapped_chest", "base", "trapped_chest", "bottom"),
("trapped_chest", "knob", "trapped_chest", "lock"),
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
(
"trapped_chest_right",
"base",
"trapped_chest_right",
"bottom",
),
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
("ender_chest", "base", "ender_chest", "bottom"),
("ender_chest", "knob", "ender_chest", "lock"),
("shulker_box", "base", "shulker_box", "base"),
("shulker_box", "lid", "shulker_box", "lid"),
("banner", "stand", "standing_banner", "pole"),
("banner", "top", "standing_banner", "bar"),
("banner", "top", "wall_banner", "bar"),
("banner", "slate", "standing_banner_flag", "flag"),
("banner", "slate", "wall_banner_flag", "flag"),
("head_skeleton", "head", "skeleton_skull", "head"),
(
"head_wither_skeleton",
"head",
"wither_skeleton_skull",
"head",
),
("head_zombie", "head", "zombie_head", "head"),
("head_creeper", "head", "creeper_head", "head"),
("head_dragon", "head", "dragon_skull", "head"),
("head_dragon", "jaw", "dragon_skull", "jaw"),
("head_piglin", "head", "piglin_head", "head"),
("head_piglin", "left_ear", "piglin_head", "left_ear"),
("head_piglin", "right_ear", "piglin_head", "right_ear"),
("head_player", "head", "player_head", "head"),
("bell", "body", "bell", "bell_body"),
("bell", "base", "bell", "bell_base"),
("enchanting_book", "cover_right", "book", "right_lid"),
("enchanting_book", "cover_left", "book", "left_lid"),
("enchanting_book", "pages_right", "book", "right_pages"),
("enchanting_book", "pages_left", "book", "left_pages"),
(
"enchanting_book",
"flipping_page_right",
"book",
"flip_page1",
),
(
"enchanting_book",
"flipping_page_left",
"book",
"flip_page2",
),
("enchanting_book", "book_spine", "book", "seam"),
("lectern_book", "cover_right", "lectern_book", "right_lid"),
("lectern_book", "pages_left", "lectern_book", "left_pages"),
(
"lectern_book",
"flipping_page_right",
"lectern_book",
"flip_page1",
),
("lectern_book", "book_spine", "lectern_book", "seam"),
("conduit", "base", "conduit_shell", "shell"),
("conduit", "cage", "conduit_cage", "shell"),
("conduit", "wind", "conduit_wind", "wind"),
("conduit", "eye", "conduit_eye", "eye"),
("decorated_pot", "neck", "decorated_pot_base", "neck"),
("decorated_pot", "top", "decorated_pot_base", "top"),
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
("decorated_pot", "front", "decorated_pot_sides", "front"),
("decorated_pot", "back", "decorated_pot_sides", "back"),
("decorated_pot", "left", "decorated_pot_sides", "left"),
("decorated_pot", "right", "decorated_pot_sides", "right"),
];
// One file per name with each of its parts, and a stub of each model holding its parts
// (chest models all of theirs, so a misplaced entry shows).
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (file, part, key, vanilla) in cases {
files.entry(file).or_default().insert(part);
trees.entry(key).or_default().insert(vanilla);
}
for key in ["chest", "double_chest_left", "double_chest_right"] {
trees
.entry(key)
.or_default()
.extend(["bottom", "lid", "lock"]);
}
let files_by_name = files.clone();
let files: Vec<_> = files
.iter()
.map(|(file, parts)| {
let parts: Vec<_> = parts.iter().copied().collect();
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
})
.collect();
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
let root = pack("parts", &files);
let vanilla: HashMap<&'static str, ModelPart> = trees
.iter()
.map(|(key, parts)| {
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
(*key, plane(parts))
})
.collect();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, _), unused) = result.unwrap();
for (file, part, key, vanilla) in cases {
assert!(
replaced(&models[key], vanilla),
"{file}.jem {part}: {key} {vanilla}"
);
// The box is this entry's own: swapped names (left and right, body and base) show.
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
assert_eq!(
depth(&models[key], vanilla),
own as f32,
"{file}.jem {part}: {key} {vanilla}"
);
}
assert_eq!(unused, Vec::new());
}
#[test]
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
let root = pack(
"chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
{{"part": "lid", {BOX}}},
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
{{"part": "bottom", {BOX}}},
{{"part": "tail", {BOX}}}]}}"#
),
)],
);
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
let chest = &models["chest"];
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
assert!(replaced(chest, "lid"));
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
assert!(replaced(chest, "bottom"));
// Attached: the lock keeps its face beside the scaled box.
let lock = chest.child("lock").unwrap();
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
// `tail` names no chest part: the one warning.
assert_eq!(
unused,
[(
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
"tail".to_owned()
)]
);
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
// only the chest takes the texture.
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
let lid = models[key].child("lid").unwrap();
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
}
assert_eq!(
textures,
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
);
}
#[test]
fn banners_split_one_file_and_books_fall_back() {
let root = pack(
"banner",
&[
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
],
);
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
let wall = plane(vec![("bar", plane(vec![]))]);
let flag = plane(vec![("flag", plane(vec![]))]);
let book = plane(vec![("left_lid", plane(vec![]))]);
let vanilla: HashMap<_, _> = [
("standing_banner", banner),
("wall_banner", wall),
("wall_banner_flag", flag),
("book", book),
]
.into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
assert!(replaced(&models["standing_banner"], "pole"));
assert!(replaced(&models["standing_banner"], "bar"));
assert!(replaced(&models["wall_banner"], "bar"));
assert!(replaced(&models["wall_banner_flag"], "flag"));
assert!(replaced(&models["book"], "left_lid"));
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
let lectern = models["lectern_book"].child("left_lid").unwrap();
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
assert!(textures.is_empty());
// Each entry found its part in one of the models: no warning.
assert_eq!(unused, Vec::new());
}
#[test]
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, _) = result.unwrap();
assert!(replaced(&models["book"], "left_lid"));
assert!(replaced(&models["lectern_book"], "left_lid"));
}
#[test]
fn a_shared_file_paints_only_the_models_it_reshapes() {
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
// vanilla geometry and so their own textures.
let file = format!(
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
);
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
let vanilla: HashMap<_, _> = [
"conduit_shell",
"conduit_cage",
"conduit_wind",
"conduit_eye",
]
.map(|key| {
let part = if key == "conduit_eye" { "eye" } else { "shell" };
(key, plane(vec![(part, plane(vec![]))]))
})
.into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, textures) = result.unwrap();
assert!(replaced(&models["conduit_eye"], "eye"));
let painted: BTreeSet<_> = textures.keys().copied().collect();
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
}
}
@@ -20,12 +20,7 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use serde_json::Value;
use std::{
collections::HashMap,
f32::consts::PI,
path::Path,
sync::{Arc, Mutex, PoisonError, RwLock},
};
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
/// normalized UVs) and named children.
@@ -125,12 +120,7 @@ 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(
@@ -152,7 +142,6 @@ 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));
@@ -166,19 +155,17 @@ pub fn draw_part(
normal
};
let color = pack_color(tint, 255, diffuse(normal, lights));
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,
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 {
@@ -784,8 +771,6 @@ 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,
@@ -807,102 +792,16 @@ pub struct Scene<'a> {
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
vanilla: Arc<BlockEntityShapes>,
/// What draws: `vanilla`, or the packs' custom entity models over it.
shapes: RwLock<Arc<BlockEntityShapes>>,
models: HashMap<&'static str, ModelPart>,
kinds: Vec<Kind>,
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
/// models over them ([`crate::block_entity_cem`]).
#[derive(Default)]
pub struct BlockEntityShapes {
models: HashMap<&'static str, ModelPart>,
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
textures: HashMap<&'static str, String>,
}
/// The chest models, each baked with its lid closed.
const CHEST_MODELS: [&str; 7] = [
"chest",
"double_chest_left",
"double_chest_right",
"trapped_chest",
"trapped_chest_left",
"trapped_chest_right",
"ender_chest",
];
impl BlockEntityShapes {
fn new(
models: HashMap<&'static str, ModelPart>,
textures: HashMap<&'static str, String>,
) -> Self {
let closed_chests = CHEST_MODELS
.iter()
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
.collect();
Self {
models,
closed_chests,
textures,
}
}
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
fn texture<'a>(
&'a self,
key: &str,
vanilla: &'a str,
sprites: &HashMap<String, Rect>,
) -> &'a str {
match self.textures.get(key) {
Some(texture) if sprites.contains_key(texture) => texture,
_ => vanilla,
}
}
}
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
/// over the whole texture.
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
[false, true].map(|nether| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
glow: None,
},
&mut vertices,
);
vertices
})
})
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
@@ -985,8 +884,6 @@ impl BlockEntityRenderer {
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
@@ -1013,7 +910,43 @@ impl BlockEntityRenderer {
Ok(renderer)
}
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
let closed_chests = [false, true].map(|nether| {
["chest", "double_chest_left", "double_chest_right"].map(|name| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
if let Some(model) = models.get(name) {
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
},
&mut vertices,
);
}
vertices
})
})
});
let kinds = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
@@ -1026,31 +959,14 @@ impl BlockEntityRenderer {
})
.collect();
Self {
shapes: RwLock::new(vanilla.clone()),
vanilla,
models,
kinds,
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
None => self.vanilla.clone(),
}
}
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
}
fn shapes(&self) -> Arc<BlockEntityShapes> {
self.shapes
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
@@ -1344,7 +1260,6 @@ impl BlockEntityRenderer {
world_chests: bool,
out: &mut Vec<Vertex>,
) {
let shapes = self.shapes();
let sprite = |name: &str| scene.sprites.get(name).copied();
let paint = |name: &str, tint: u32, packed: u8| {
let rect = if crumbling {
@@ -1364,9 +1279,6 @@ 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]| {};
@@ -1437,34 +1349,12 @@ impl BlockEntityRenderer {
} else {
*texture
};
// Trapped and ender chests have models of their own when a `.jem` reshapes
// them; copper chests have no CEM name and draw vanilla's chest.
let shapes: &BlockEntityShapes =
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
&shapes
} else {
&self.vanilla
};
let (own, chest) = match (block.as_str(), part) {
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
("trapped_chest", ChestPart::Left) => {
("trapped_chest_left", "double_chest_left")
}
("trapped_chest", ChestPart::Right) => {
("trapped_chest_right", "double_chest_right")
}
("ender_chest", _) => ("ender_chest", "chest"),
(_, ChestPart::Single) => ("chest", "chest"),
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
};
let key = if shapes.models.contains_key(own) {
own
} else {
chest
};
let texture = shapes.texture(key, texture, scene.sprites);
let Some(model) = shapes.models.get(key) else {
let Some(model) = self.models.get(model) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1480,25 +1370,19 @@ impl BlockEntityRenderer {
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
}
if world_chests && open == 0.0 {
if let (Some(paint), Some(closed)) = (
paint(texture, 0xFFFFFF, packed),
shapes.closed_chests.get(key),
) {
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
let vertices = &self.closed_chests[usize::from(scene.nether)]
[*part as usize][*y_rot as usize / 90];
let origin = base.w_axis.truncate();
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
}));
}
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
}));
}
return;
}
@@ -1519,12 +1403,8 @@ impl BlockEntityRenderer {
}
Kind::Shulker { texture, facing } => {
let (Some(model), Some(paint)) = (
shapes.models.get("shulker_box"),
paint(
shapes.texture("shulker_box", texture, scene.sprites),
0xFFFFFF,
packed,
),
self.models.get("shulker_box"),
paint(texture, 0xFFFFFF, packed),
) else {
return;
};
@@ -1548,13 +1428,12 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &lid, paint, out);
}
Kind::Banner { color, wall, angle } => {
let (main_key, flag_key) = if *wall {
let (main, flag) = if *wall {
("wall_banner", "wall_banner_flag")
} else {
("standing_banner", "standing_banner_flag")
};
let (Some(main), Some(flag)) =
(shapes.models.get(main_key), shapes.models.get(flag_key))
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
else {
return;
};
@@ -1575,13 +1454,8 @@ impl BlockEntityRenderer {
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
}
};
let banner_base = "entity/banner/banner_base";
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
draw_part(main, "root", m, &none, paint, out);
}
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
draw_part(flag, "root", m, &wave, paint, out);
}
// Pattern passes must not multiply the crumbling decal.
@@ -1623,12 +1497,9 @@ impl BlockEntityRenderer {
None
};
let has_profile = skin.is_some();
// A `.jem` texture stands in for the default skin, not a profile's.
let texture = skin
.as_deref()
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -1670,12 +1541,8 @@ impl BlockEntityRenderer {
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
shapes.models.get("bell"),
paint(
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
0xFFFFFF,
packed,
),
self.models.get("bell"),
paint("entity/bell/bell_body", 0xFFFFFF, packed),
) else {
return;
};
@@ -1703,8 +1570,8 @@ impl BlockEntityRenderer {
}
Kind::DecoratedPot { y_rot } => {
let (Some(base_model), Some(sides)) = (
shapes.models.get("decorated_pot_base"),
shapes.models.get("decorated_pot_sides"),
self.models.get("decorated_pot_base"),
self.models.get("decorated_pot_sides"),
) else {
return;
};
@@ -1732,20 +1599,17 @@ impl BlockEntityRenderer {
}
}
let first = out.len();
let pot_base = "entity/decorated_pot/decorated_pot_base";
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
draw_part(base_model, "root", m, &none, paint, out);
}
// PotDecorations (back, left, right, front); items without a pattern use the blank
// side (a `.jem`'s texture, if any).
let blank = "entity/decorated_pot/decorated_pot_side";
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
let texture = item
.as_ref()
.and_then(|i| self.pot_patterns.get(i))
.map_or(blank, String::as_str);
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
if let (Some(part), Some(paint)) =
(sides.child(side), paint(texture, 0xFFFFFF, packed))
{
@@ -1795,14 +1659,11 @@ impl BlockEntityRenderer {
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
// ConduitRenderer.extractRenderState.
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
let tex = |key: &str, name: &str| {
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
};
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
if !active {
let (Some(model), Some(paint)) = (
shapes.models.get("conduit_shell"),
tex("conduit_shell", "entity/conduit/base"),
) else {
let (Some(model), Some(paint)) =
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
else {
return;
};
let m = base
@@ -1817,10 +1678,9 @@ impl BlockEntityRenderer {
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
let hh = hh * hh + hh;
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
if let (Some(model), Some(paint)) = (
shapes.models.get("conduit_cage"),
tex("conduit_cage", "entity/conduit/cage"),
) {
if let (Some(model), Some(paint)) =
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
{
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
let m = base
* Mat4::from_translation(lift)
@@ -1836,9 +1696,7 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/wind"
};
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
{
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
let turn = match phase {
1 => Mat4::from_rotation_x(PI / 2.0),
2 => Mat4::from_rotation_z(PI / 2.0),
@@ -1856,9 +1714,7 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/closed_eye"
};
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
{
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
let m = base
* Mat4::from_translation(lift)
* Mat4::from_scale(Vec3::splat(0.5))
@@ -1869,14 +1725,9 @@ impl BlockEntityRenderer {
}
}
Kind::EnchantingTable => {
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
shapes.models.get("book"),
paint(
shapes.texture("book", book, scene.sprites),
0xFFFFFF,
packed,
),
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
) else {
return;
};
@@ -1895,16 +1746,9 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
}
Kind::Lectern { y_rot } => {
// Its own `.jem`'s book, else the enchanting table's.
let key = if shapes.models.contains_key("lectern_book") {
"lectern_book"
} else {
"book"
};
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
shapes.models.get(key),
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
) else {
return;
};
@@ -1935,7 +1779,7 @@ impl BlockEntityRenderer {
_ => "copper_golem",
};
let (Some(model), Some(paint)) =
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -2262,7 +2106,6 @@ mod tests {
max_y: 320,
world: &world,
sprites: &base_sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2462,7 +2305,6 @@ mod tests {
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(false),
glow: None,
},
&mut out,
);
@@ -2471,55 +2313,6 @@ 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.
@@ -2548,7 +2341,6 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2587,8 +2379,7 @@ mod tests {
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
vanilla: Default::default(),
shapes: Default::default(),
models: HashMap::new(),
kinds: vec![
Kind::Sign {
hanging: false,
@@ -2601,6 +2392,7 @@ mod tests {
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
@@ -2653,7 +2445,6 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &first_sign,
@@ -2762,7 +2553,6 @@ mod tests {
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
@@ -2784,131 +2574,4 @@ mod tests {
"entitySolid culls the pot's back faces"
);
}
#[test]
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
use crate::block_entity_cem::tests::{BOX, chest, pack};
let vanilla = Arc::new(BlockEntityShapes::new(
HashMap::from([("chest", chest())]),
HashMap::new(),
));
let renderer = BlockEntityRenderer {
shapes: RwLock::new(vanilla.clone()),
vanilla,
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
let root = pack(
"render-chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png",
"models": [{{"part": "lid", {BOX}}}]}}"#
),
)],
);
renderer.set_shapes(renderer.shapes_for(&root));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&empty);
let rect = |u0: f32| Rect {
u0,
v0: 0.0,
u1: u0 + 0.2,
v1: 0.2,
w: 16,
h: 16,
};
let sprites: HashMap<String, Rect> = [
("entity/chest/normal", rect(0.0)),
("entity/chest/ender", rect(0.2)),
("entity/chest/fancy", rect(0.4)),
("entity/chest/copper", rect(0.6)),
]
.map(|(name, r)| (name.to_owned(), r))
.into();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
let kind = Kind::Chest {
block: block.into(),
texture,
part: ChestPart::Single,
y_rot: 0.0,
double_with: None,
};
let mut out = Vec::new();
renderer.render_one(
&scene,
[0; 3],
&kind,
Mat4::IDENTITY,
0xF0,
diffuse_lights(false),
false,
true,
&mut out,
);
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
};
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
// texture turns festive in late December.)
for (block, texture, u0, faces) in [
("chest", "entity/chest/normal", 0.4, 8),
("ender_chest", "entity/chest/ender", 0.2, 3),
("copper_chest", "entity/chest/copper", 0.6, 3),
] {
let out = draw(&renderer, block, texture);
assert_eq!(out.len(), faces * 4, "{block}");
assert!(
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
"{block}"
);
}
// A copper chest draws exactly what it draws without any pack.
let plain = BlockEntityRenderer {
shapes: RwLock::new(renderer.vanilla.clone()),
vanilla: renderer.vanilla.clone(),
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
assert_eq!(
draw(&renderer, "copper_chest", "entity/chest/copper"),
draw(&plain, "copper_chest", "entity/chest/copper")
);
// The atlas has no such texture: vanilla's.
let shapes = renderer.shapes();
assert_eq!(
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
"entity/chest/normal"
);
}
}
+18 -24
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, path::Path};
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,28 +158,6 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -194,7 +172,23 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> = brewing_sources(root);
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
@@ -419,31 +419,6 @@ 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 {
@@ -506,19 +481,6 @@ 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]);
@@ -644,10 +644,6 @@ impl RolledLoot {
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Server-owned mod data attached to this chunk.
pub attachments: horizon_world::attachments::Attachments,
/// Attachment maps of the chunk's block entities, on a save snapshot.
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
@@ -8,13 +8,7 @@ use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
@@ -100,98 +94,18 @@ pub enum BlockEntity {
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// 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
/// A mod's block entity: 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 ModBlockEntityFields {
pub struct ModBlockEntity {
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),
@@ -221,23 +135,6 @@ 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
@@ -691,64 +588,3 @@ 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)),
])
);
}
}
+19 -206
View File
@@ -4,7 +4,6 @@
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use crate::cit_glint::{Global, StackGlint};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
@@ -17,7 +16,7 @@ use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::sync::Mutex;
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
@@ -28,8 +27,13 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
let props = read_properties(&root.join(&path))?;
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
@@ -40,22 +44,10 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
rules
}
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
/// be read.
pub fn read_properties(path: &Path) -> Option<Properties> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
Some(Properties::parse_equals_first(&text))
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// The pack's `cit.properties`.
pub global: Global,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
@@ -64,7 +56,6 @@ pub struct CitRules {
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
}
#[derive(Debug, Default)]
@@ -79,10 +70,7 @@ const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
Self::new(load_rules(root))
}
/// `rules` in priority order, the winner first.
@@ -118,19 +106,6 @@ impl CitRules {
self.rules.is_empty()
}
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
/// match no stack, so vanilla's glint and the other rules of their layer stay.
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
self.any_item.retain(|&i| keep(i as usize));
let named = &mut self.by_item[Kind::Enchantment as usize];
for list in named.values_mut() {
list.retain(|&i| keep(i as usize));
}
named.retain(|_, list| !list.is_empty());
lock(&self.enchanted).clear();
lock(&self.glints).clear();
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
@@ -148,14 +123,13 @@ impl CitRules {
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
/// GUI), in priority order.
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
@@ -164,7 +138,7 @@ impl CitRules {
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, hand),
(1, Kind::Enchantment, Hand::Any),
stack,
max_damage,
comps.as_ref(),
@@ -173,62 +147,24 @@ impl CitRules {
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
/// pack changes no glint. `foil` is whether the stack draws a glint at all
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
pub fn glint(
&self,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
foil: bool,
) -> Option<Arc<StackGlint>> {
let no_rules =
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
// Before the cache, which `foil` is not part of.
if !foil || (no_rules && self.global.use_glint) {
return None;
}
let comps = encode_components(stack);
let key = cache_key(
(2, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
if let Some(hit) = lock(&self.glints).get(&key) {
return Some(hit.clone());
}
let matched = self.enchantments(stack, hand, max_damage);
let glint = Arc::new(StackGlint::new(
&self.rules,
&matched,
stack.crafting_enchantments(),
&self.global,
));
remember(&self.glints, key, glint.clone());
Some(glint)
}
/// Whether rule `i` matches the stack drawn in `hand`.
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Hand,
hand: Option<Hand>,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if rule.hand != Hand::Any && rule.hand != hand {
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
@@ -278,7 +214,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
@@ -710,7 +646,7 @@ mod tests {
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
let found = cit.enchantments(&stack, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
@@ -723,127 +659,4 @@ mod tests {
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
}
/// `stack` with its `enchantment_glint_override` set to `on`.
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
let flag = (
"minecraft:enchantment_glint_override".to_owned(),
Tag::Byte(on.into()),
);
stack.data_mut().other.push(flag);
stack
}
#[test]
fn a_stack_glint_reads_the_book_and_the_pack_file() {
let mut cit = rules(&[
("sharp", "type=enchantment\nenchantments=sharpness"),
(
"keep",
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
),
]);
// An enchanted book's glint follows its stored enchantments.
let mut book = new("enchanted_book");
book.data_mut().stored_enchantments = vec![
(Arc::from("minecraft:sharpness"), 3),
(Arc::from("minecraft:mending"), 1),
];
let book = glint(&cit, &book, true).unwrap();
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
assert_eq!(weights, [0.75, 0.25]);
assert!(book.vanilla);
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
assert_eq!(plain.layers.len(), 1);
assert!(!plain.vanilla);
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
let mut bare = rules(&[("stick", "items=stick")]);
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
bare.global.use_glint = false;
assert!(
!glint(&bare, &overridden(new("stick"), true), false)
.unwrap()
.vanilla
);
cit.global.use_glint = false;
assert!(
glint(&cit, &overridden(new("stick"), true), false)
.unwrap()
.is_empty()
);
}
#[test]
fn only_a_stack_with_a_glint_gets_layers() {
let cit = rules(&[("any", "type=enchantment")]);
assert!(glint(&cit, &new("stick"), false).is_none());
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
assert!(glint(&cit, &off, false).is_none());
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
assert_eq!(
weights,
[1.0],
"nothing to share: the rule at full strength"
);
// The same stack, asked again without its glint, is not answered from the cache.
assert!(
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
.is_none()
);
}
#[test]
fn a_rule_left_out_of_the_glints_matches_nothing() {
let mut cit = rules(&[
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
("shown", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |cit: &CitRules| -> Vec<String> {
let glint = glint(cit, &sword, false).unwrap();
let stems = glint
.layers
.iter()
.map(|l| stem(cit, Some(l.rule)).unwrap());
stems.map(str::to_owned).collect()
};
// The heavier rule wins the layer, until its texture is missing.
assert_eq!(layers(&cit), ["broken"]);
let broken = (0..cit.rules.len())
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
.unwrap();
cit.keep_glints(|i| i != broken);
assert_eq!(layers(&cit), ["shown"]);
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
// None left: vanilla's glint stays.
cit.keep_glints(|_| false);
assert!(glint(&cit, &sword, false).is_none());
}
#[test]
fn an_off_hand_layer_draws_only_in_the_off_hand() {
let cit = rules(&[
("left", "type=enchantment\nlayer=1\nhand=off"),
("both", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |hand| -> Vec<&str> {
let glint = cit.glint(&sword, hand, 0, true).unwrap();
glint
.layers
.iter()
.map(|l| stem(&cit, Some(l.rule)).unwrap())
.collect()
};
assert_eq!(layers(Hand::Off), ["both", "left"]);
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
assert_eq!(layers(Hand::Main), ["both"]);
}
}
@@ -1,319 +0,0 @@
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
//! names its textures after the armour texture files of before 1.21.2
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
use crate::cit_assets::{rule_sprites, sprite_id};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
/// overlay (`leather_overlay`) or the main texture.
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
// The equipment assets renamed from their old textures; the rest kept their names.
let old = match path {
"turtle_scute" => "turtle",
other => other,
};
let layer = if leggings { 2 } else { 1 };
let suffix = if overlay { "_overlay" } else { "" };
format!("{old}_layer_{layer}{suffix}")
}
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
/// else its `texture`.
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
rule.textures
.iter()
.find(|(name, _)| name == layer)
.map(|(_, path)| path.as_str())
.or(rule.texture.as_deref())
}
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
/// vanilla's sprites.
fn atlas_name(resource: &str) -> Option<String> {
let id = sprite_id(resource)?;
Some(match id.strip_prefix("minecraft:") {
Some(name) => name.to_owned(),
None => id,
})
}
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
atlas_name(armor_texture(
rule,
&armor_layer_name(asset, leggings, overlay),
)?)
}
/// The atlas sprite an elytra rule puts on the wings.
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
atlas_name(armor_texture(rule, "elytra")?)
}
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
/// a name the atlas lacks; nothing is allocated without a rule.
pub fn worn_layer_sprite(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
) -> Option<String> {
let leggings = kind == "humanoid_leggings";
let overlay = vanilla.ends_with("_overlay");
layer_sprite(rule?, asset, leggings, overlay)
}
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
/// cape, else the equipment asset's own wings.
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
elytra_sprite(rule?)
}
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
pub fn worn_layer<R>(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
worn_layer_sprite(rule, asset, kind, vanilla)
.and_then(|name| get(&name))
.or_else(|| get(vanilla))
}
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
/// them to the equipment asset's own wings.
pub fn wings<R>(
rule: Option<&Rule>,
cape: Option<&str>,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
wings_sprite(rule)
.and_then(|name| get(&name))
.or_else(|| cape.and_then(&get))
}
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
fn rule(path: &str, text: &str) -> Rule {
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
}
#[test]
fn layers_take_their_old_texture_names() {
assert_eq!(
armor_layer_name("minecraft:diamond", false, false),
"diamond_layer_1"
);
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
assert_eq!(
armor_layer_name("minecraft:leather", false, true),
"leather_layer_1_overlay"
);
assert_eq!(
armor_layer_name("minecraft:leather", true, true),
"leather_layer_2_overlay"
);
assert_eq!(
armor_layer_name("minecraft:gold", false, false),
"gold_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:chainmail", true, false),
"chainmail_layer_2"
);
assert_eq!(
armor_layer_name("minecraft:turtle_scute", false, false),
"turtle_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:netherite", true, false),
"netherite_layer_2"
);
}
#[test]
fn a_named_layer_wins_over_the_generic_texture() {
let r = rule(
"assets/minecraft/optifine/cit/armor/blue.properties",
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
texture.diamond_layer_2=blue_legs\n",
);
let dir = "assets/minecraft/optifine/cit/armor";
assert_eq!(
armor_texture(&r, "diamond_layer_2"),
Some(format!("{dir}/blue_legs.png").as_str())
);
assert_eq!(
armor_texture(&r, "diamond_layer_1"),
Some(format!("{dir}/generic.png").as_str())
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
);
}
#[test]
fn without_a_generic_texture_other_layers_stay_vanilla() {
let r = rule(
"assets/minecraft/optifine/cit/a.properties",
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
);
assert_eq!(
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/dyed")
);
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
}
#[test]
fn a_vanilla_texture_path_keeps_its_atlas_name() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
);
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
}
#[test]
fn worn_layers_pick_the_rules_texture_for_their_layer() {
let r = rule(
"assets/minecraft/optifine/cit/dye.properties",
"type=armor\nitems=leather_chestplate leather_leggings\n\
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
texture.leather_layer_2=legs\n",
);
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
let pick = |kind: &str, vanilla: &str| {
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
};
let body = "entity/equipment/humanoid/leather";
assert_eq!(pick("humanoid", body), cit("body"));
assert_eq!(
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
cit("body_dye")
);
assert_eq!(
pick(
"humanoid_leggings",
"entity/equipment/humanoid_leggings/leather"
),
cit("legs")
);
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
assert_eq!(pick("humanoid_leggings", legs_dye), None);
// No rule draws vanilla's.
assert_eq!(
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
None
);
}
#[test]
fn wings_take_the_rules_texture() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
);
assert_eq!(
wings_sprite(Some(&r)).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/wings")
);
assert_eq!(wings_sprite(None), None);
}
#[test]
fn only_armor_and_elytra_rules_reach_the_atlas() {
let rules = [
rule(
"assets/minecraft/optifine/cit/sword.properties",
"items=diamond_sword\ntexture=sword\n",
),
rule(
"assets/minecraft/optifine/cit/plate.properties",
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
texture.iron_layer_2=legs\n",
),
rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
),
rule(
"assets/minecraft/optifine/cit/glint.properties",
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
),
];
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
assert_eq!(
got,
[
"horizon_cit:minecraft/optifine/cit/legs",
"horizon_cit:minecraft/optifine/cit/plate",
"horizon_cit:minecraft/optifine/cit/wings",
]
);
}
/// An atlas lookup that knows `names`.
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
}
#[test]
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
let iron = rule(
"assets/minecraft/optifine/cit/iron.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
);
let plate = "horizon_cit:minecraft/optifine/cit/plate";
let vanilla = "entity/equipment/humanoid/iron";
let layer = |rule: Option<&Rule>, names: &[&str]| {
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
};
assert_eq!(
layer(Some(&iron), &[plate, vanilla]).as_deref(),
Some(plate)
);
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
let elytra = rule(
"assets/minecraft/optifine/cit/w.properties",
"type=elytra\ntexture=wings",
);
let w = "horizon_cit:minecraft/optifine/cit/wings";
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
assert_eq!(wings(None, None, atlas(&[w])), None);
}
}
@@ -1,342 +0,0 @@
//! 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_assets::model::{CuboidModel, TextureSlot};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
/// 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 rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
/// values. A path no id can name is skipped with a warning.
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| keep(r.kind)) {
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
}
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
/// JSON models are [`model_sprites`].
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| kind == Kind::Item)
}
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
/// that would leave the root.
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
let mut rest = value
.strip_prefix("./")
.or_else(|| value.starts_with("../").then_some(value))?;
let mut dir = file.rsplit_once('/')?.0;
while let Some(up) = rest.strip_prefix("../") {
dir = dir.rsplit_once('/')?.0;
rest = up;
}
let rest = rest
.strip_suffix(ext)
.and_then(|r| r.strip_suffix('.'))
.unwrap_or(rest);
let path = format!("{dir}/{rest}.{ext}");
horizon_optifine::paths::valid(&path).then_some(path)
}
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
let id = if ext == "png" {
sprite_id(&path)
} else {
model_id(&path)
}?;
Some((id, path))
}
/// Parents followed from a rule's model before the rest are left to the library.
const MAX_PARENTS: usize = 16;
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
/// with the parents they reach by relative path. Their `./x` texture and parent values become
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
/// library already. A file that does not read or parse is left out, and a rule's layer that
/// names it (or a model whose parents reach it) keeps its vanilla model.
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let mut seen = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.models.iter().map(|(_, path)| path);
for path in rule.model.iter().chain(named) {
load_model(root, path, 0, &mut seen, &mut out);
}
}
out
}
fn load_model(
root: &Path,
resource: &str,
depth: usize,
seen: &mut BTreeSet<String>,
out: &mut BTreeMap<String, CuboidModel>,
) {
let Some(id) = model_id(resource) else {
log::warn!("custom item textures: {resource} is not a valid model id");
return;
};
let ours = id
.strip_prefix(NAMESPACE)
.is_some_and(|r| r.starts_with(':'));
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
return;
}
let model = fs::read_to_string(root.join(resource))
.map_err(|e| e.to_string())
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
let mut model = match model {
Ok(model) => model,
Err(e) => {
log::warn!("custom item textures: skipped model {resource}: {e}");
return;
}
};
for slot in model.textures.values_mut() {
if let TextureSlot::Material(m) = slot
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
{
m.sprite = id;
}
}
let parent = model
.parent
.as_deref()
.and_then(|p| rebase(resource, p, "json"));
if let Some((id, path)) = parent {
model.parent = Some(id);
load_model(root, &path, depth + 1, seen, out);
}
out.insert(id, model);
}
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
let prefix = format!("{NAMESPACE}:");
models
.values()
.flat_map(|m| m.textures.values())
.filter_map(|slot| match slot {
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
_ => None,
})
.collect()
}
#[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")
);
}
#[test]
fn rule_models_load_with_their_relative_textures_and_parents() {
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(cit.join("swords")).unwrap();
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
write(
"swords/flame.json",
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
);
write(
"base.json",
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
);
let props =
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
root.join(p).is_file()
})
.unwrap();
let models = load_models(&root, &[rule]);
let _ = fs::remove_dir_all(&root);
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
assert_eq!(
models.keys().cloned().collect::<Vec<_>>(),
[cit("base"), cit("swords/flame")]
);
let flame = &models[&cit("swords/flame")];
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
assert_eq!(
models[&cit("base")].parent.as_deref(),
Some("minecraft:item/handheld")
);
assert_eq!(
model_sprites(&models).into_iter().collect::<Vec<_>>(),
[cit("glow"), cit("swords/flame")]
);
}
#[test]
fn relative_values_stay_in_the_root() {
let file = "assets/minecraft/optifine/cit/a/m.json";
let at = |v: &str| relative(file, v, "png");
assert_eq!(
at("./x").as_deref(),
Some("assets/minecraft/optifine/cit/a/x.png")
);
assert_eq!(
at("./../x.png").as_deref(),
Some("assets/minecraft/optifine/cit/x.png")
);
assert_eq!(
at("../../x").as_deref(),
Some("assets/minecraft/optifine/x.png")
);
assert_eq!(at("item/x"), None);
assert_eq!(at("./../../../../../x"), None);
assert_eq!(at("./a/../x"), None);
}
}
@@ -1,386 +0,0 @@
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
//!
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
//! cycle's fading follow it, except where noted.
pub use horizon_optifine::cit::{Global, Method};
use crate::cit::read_properties;
use horizon_optifine::cit::Rule;
use std::path::Path;
/// The pack's global `cit.properties` under the overlay resource root: the first of
/// `Global::PATHS` that exists, else the defaults.
pub fn load_global(root: &Path) -> Global {
Global::PATHS
.iter()
.find_map(|path| read_properties(&root.join(path)))
.map(|props| Global::parse(&props))
.unwrap_or_default()
}
/// One glint layer a stack draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlintLayer {
/// The rule, an index into `CitRules::rules`.
pub rule: usize,
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
pub weight: f32,
/// Its `duration`, for `cycle`.
duration: f32,
}
/// What a stack's glint is made of, worked out when the stack changes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackGlint {
/// Bottom first (`layer` ascending).
pub layers: Vec<GlintLayer>,
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
pub vanilla: bool,
/// `fade` when the layers take turns (`method=cycle`).
cycle_fade: Option<f32>,
}
impl StackGlint {
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
/// (`ItemStack::crafting_enchantments`).
pub fn new(
rules: &[Rule],
matched: &[usize],
enchantments: &[(impl AsRef<str>, u32)],
global: &Global,
) -> Self {
// The highest priority rule of each layer.
let mut layers: Vec<usize> = Vec::new();
for &i in matched {
let layer = rules[i].glint.layer;
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
layers.push(i);
}
}
layers.sort_by_key(|&i| rules[i].glint.layer);
let cycle = global.method == Method::Cycle;
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
// the bottom layers, though, and caps cycles as well).
if let Some(cap) = global.cap.filter(|_| !cycle) {
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
layers = layers.split_off(layers.len().saturating_sub(cap));
}
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
let vanilla = global.use_glint
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
// CIT Resewn: shares are against every enchantment of the stack, not only those the
// layers name.
let levels = enchantments.iter().map(|(_, level)| *level as f32);
let total = match global.method {
Method::Average => levels.sum::<f32>(),
Method::Layered => levels.fold(0.0, f32::max),
Method::None | Method::Cycle => 0.0,
};
let shared = matches!(global.method, Method::Average | Method::Layered);
let layers = layers
.into_iter()
.map(|i| {
let rule = &rules[i];
let named = &rule.enchantments;
let weight = if !shared || named.is_empty() {
1.0
} else {
// The highest level among the enchantments the rule names whose level also
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
// stack has, whatever its level).
let in_range = |level: u32| {
let level = i32::try_from(level).unwrap_or(i32::MAX);
rule.enchantment_levels
.as_ref()
.is_none_or(|r| r.contains(level))
};
let level = enchantments
.iter()
.filter(|(id, level)| {
let id: &str = id.as_ref();
named.iter().any(|n| n == id) && in_range(*level)
})
.map(|(_, level)| *level as f32)
.fold(0.0, f32::max);
if total > 0.0 { level / total } else { 0.0 }
};
GlintLayer {
rule: i,
weight,
duration: rule.glint.duration,
}
})
.collect();
Self {
layers,
vanilla,
cycle_fade: cycle.then_some(global.fade),
}
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
/// its turn.
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
let layer = self.layers[index];
match self.cycle_fade {
None => layer.weight,
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
}
}
}
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
/// fades into the next over `fade` seconds, the last into the first. With no time at all
/// (every `duration` and `fade` 0) the bottom layer shows alone.
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
let n = layers.len();
let fade = f64::from(fade);
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
if n == 1 || period <= 0.0 {
return if index == 0 { 1.0 } else { 0.0 };
}
let mut t = seconds.rem_euclid(period);
for (i, layer) in layers.iter().enumerate() {
let duration = f64::from(layer.duration);
if t < duration {
return if i == index { 1.0 } else { 0.0 };
}
t -= duration;
// Defensive: the last fade also takes anything rounding might leave of the period
// (none seen), ending fully on the first layer.
if t < fade || i + 1 == n {
let into = if fade > 0.0 {
(t / fade).min(1.0) as f32
} else {
1.0
};
return if i == index {
1.0 - into
} else if (i + 1) % n == index {
into
} else {
0.0
};
}
t -= fade;
}
// Not reached, as the last layer returns above; no layer rather than a panic.
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
const DIR: &str = "assets/minecraft/optifine/cit";
/// An enchantment rule named `name` with `text` after `type` and `texture`.
fn rule(name: &str, text: &str) -> Rule {
let text = format!("type=enchantment\ntexture=glint\n{text}");
let path = format!("{DIR}/{name}.properties");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
fn global(text: &str) -> Global {
Global::parse(&Properties::parse_equals_first(text))
}
fn rules_of(layers: &StackGlint) -> Vec<usize> {
layers.layers.iter().map(|l| l.rule).collect()
}
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
#[test]
fn one_rule_per_layer_bottom_first() {
let rules = [
rule("a", "layer=2"),
rule("b", "layer=0"),
rule("c", "layer=2"),
rule("d", "layer=-1"),
];
// Priority order a, b, c, d: c loses layer 2 to a.
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [3, 1, 0]);
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [2]);
}
#[test]
fn average_and_layered_shares() {
let rules = [
rule("sharp", "enchantments=sharpness smite"),
rule("unb", "layer=1\nenchantments=unbreaking"),
rule("any", "layer=2"),
rule("fire", "layer=3\nenchantments=fire_aspect"),
];
let weights = |method: &str| {
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
};
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
// A rule naming no enchantment is at full strength, one the stack lacks at none.
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
assert_eq!(weights("method=cycle"), [1.0; 4]);
assert_eq!(weights("method=none"), [1.0; 4]);
}
#[test]
fn the_level_is_one_the_rules_levels_allow() {
let rules = [rule(
"holy",
"enchantments=sharpness smite\nenchantmentLevels=1-2",
)];
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
}
#[test]
fn cap_keeps_the_topmost_layers() {
let rules = [
rule("a", "layer=0"),
rule("b", "layer=1"),
rule("c", "layer=2"),
];
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
assert_eq!(rules_of(&g), [1, 2]);
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
assert!(g.is_empty());
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
assert_eq!(rules_of(&g), [2]);
// Cycles show every layer in turn.
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
assert_eq!(rules_of(&g), [0, 1, 2]);
}
#[test]
fn vanilla_glint() {
let plain = rule("plain", "");
let keep = rule("keep", "layer=1\nuseGlint=true");
let rules = [plain, keep];
let vanilla = |matched: &[usize], text: &str| {
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
};
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
assert!(!vanilla(&[0], ""), "a rule replaces it");
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
assert!(!vanilla(&[], "useGlint=false"));
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
// Only the layers `cap` keeps count.
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
assert!(!g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
assert!(g.is_empty() && g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
assert!(!g.vanilla);
}
#[test]
fn cycles_take_turns_and_fade() {
let rules = [
rule("a", "duration=2"),
rule("b", "layer=1\nduration=1"),
rule("c", "layer=2\nduration=1"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.5"),
);
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
// One layer always shows.
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
assert_eq!(one.intensity(0, 0.7), 1.0);
}
#[test]
fn a_cycle_always_shows_a_whole_layer() {
let rules = [
rule("a", "duration=0.1"),
rule("b", "layer=1\nduration=0.7"),
rule("c", "layer=2\nduration=0.2"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.3"),
);
// Every millisecond of the first two seconds, and just under a period, the last fade's
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
// defensive.)
let period: f64 = [0.1f32, 0.7, 0.2]
.map(|d| f64::from(d) + f64::from(0.3f32))
.iter()
.sum();
let last = f64::from_bits(period.to_bits() - 1);
for t in (0..2000_i32)
.map(|i| f64::from(i) * 0.001)
.chain([last, period + last])
{
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
}
// No time at all: the bottom layer alone, never NaN.
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
for t in [0.0, 0.5, 7.25] {
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
}
}
#[test]
fn the_first_global_file_wins() {
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(load_global(&root), Global::default());
let write = |path: &str, text: &[u8]| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, text).unwrap();
};
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
write(
"assets/minecraft/optifine/cit/cit.properties",
b"# caf\xE9\nmethod=cycle",
);
assert_eq!(load_global(&root).method, Method::Cycle);
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
assert_eq!(load_global(&root).method, Method::Cycle);
write(
"assets/minecraft/optifine/cit.properties",
b"method=layered",
);
assert_eq!(load_global(&root).method, Method::Layered);
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
let g = load_global(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
}
}
+19 -577
View File
@@ -41,35 +41,9 @@ 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)]
@@ -79,15 +53,8 @@ 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>,
@@ -131,24 +98,14 @@ impl ConnectedTextures {
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules with a custom name load no tiles: section meshes see no block entity.
rules.retain(|r| r.name.is_none());
// 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());
// 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| {
@@ -173,11 +130,7 @@ impl ConnectedTextures {
})
.collect::<crate::Result<Vec<_>>>();
match tiles {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
overlay: OverlayJoin::default(),
}),
Ok(tiles) => Some(LoadedRule { rule, tiles }),
Err(e) => {
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
None
@@ -191,11 +144,7 @@ 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 || !r.overlay.tiles.is_empty())
{
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
out.face_layers = (0..registry.len() as u32)
.map(|id| {
Face::ALL
@@ -203,23 +152,6 @@ 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
}
@@ -234,48 +166,6 @@ 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.
@@ -323,13 +213,6 @@ 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) {
@@ -339,7 +222,6 @@ 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);
}
@@ -350,35 +232,6 @@ 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]
@@ -387,8 +240,7 @@ impl ConnectedTextures {
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
/// with `orient=texture`.
/// by rules that test one.
pub fn pick<'b>(
&self,
state: u32,
@@ -397,8 +249,6 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: impl Fn([i32; 3]) -> u32,
biome: impl Fn() -> Option<&'b str>,
texture: impl Fn() -> Option<Axes>,
mut overlay: impl FnMut(Overlay),
) -> Pick {
let (mut by_state, mut by_layer) = (
self.list(&self.by_state, state as usize),
@@ -429,65 +279,14 @@ 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,
&texture,
&mut overlay,
)
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
};
if picked == layer {
return pick;
}
let own = self.list(&self.by_state, state as usize);
// Rules tried on the face's sprite already (those before this one) are not tried
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
j != i
&& !(j < i && first.contains(&j))
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
});
for (_, r) in again {
// Joins by tile still compare the face's own sprite with its neighbours'.
match self.apply(
r,
state,
layer,
face,
pos,
&neighbour,
&biome,
&texture,
&mut overlay,
) {
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
return pick;
}
}
}
/// 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).
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
#[allow(clippy::too_many_arguments)]
fn apply<'b>(
&self,
@@ -498,8 +297,6 @@ impl ConnectedTextures {
pos: [i32; 3],
neighbour: &impl Fn([i32; 3]) -> u32,
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
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
@@ -515,17 +312,8 @@ 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)
@@ -539,46 +327,12 @@ impl ConnectedTextures {
};
let axes = |face| match (rule.orient, info.axis) {
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
// The quad's own texture directions, whatever face `symmetry` stands it for.
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
// the standard way.
(Orient::Texture, _) if rule.method != Method::Overlay => {
texture().unwrap_or(Axes::cube(face))
}
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
_ => Axes::cube(face),
};
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 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 {
let index = match rule.method {
Method::Ctm => geometry::ctm_index(mask()),
Method::CtmCompact => {
let mask = mask();
@@ -623,13 +377,9 @@ impl ConnectedTextures {
let face = geometry::symmetric_face(face, rule.symmetry);
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
}
// Every overlay was mapped to its method above.
// Overlays are not loaded yet.
_ => return None,
};
if rule.method.is_overlay() {
draw(tile(index));
return None;
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some(Pick::Keep),
@@ -662,7 +412,6 @@ mod tests {
.into_iter()
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
tiles: rule
.tiles
.iter()
@@ -683,7 +432,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, glass 3.
/// upper); the stone sprite is layer 7.
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
pairs
@@ -703,15 +452,7 @@ mod tests {
ConnectedTextures::build(
rules(files),
states.iter().map(|(n, p)| Some((*n, p))),
|name| match name {
"minecraft:block/stone" => Some(7),
"minecraft:block/glass" => Some(3),
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
_ => None,
},
|name| (name == "minecraft:block/stone").then_some(7),
)
}
@@ -732,7 +473,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, || None, |_| {})
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
}
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
@@ -855,26 +596,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,
|| None,
|_| {},
);
let upper = ctm.pick(
6,
3,
Face::North,
[x, 65, 0],
|_| 0,
|| None,
|| 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:?}"));
}
@@ -887,18 +610,8 @@ 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),
|| None,
|_| {},
)
};
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);
@@ -932,7 +645,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, || 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));
@@ -997,277 +710,6 @@ 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,
|| 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,
|| 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,
|| 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 a_picked_tile_can_be_picked_again() {
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
// names that tile, so it draws in b0's place.
let chain = |t: &str| {
world(&[
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
("t.properties", t),
])
};
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
// A chained rule keeping the tile (<default>) leaves b's.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// One that names another block doesn't chain on glass.
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// An overlay tried on the face's own sprite and on the picked tile draws once.
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
// tried: it chains.
let ctm = world(&[
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
(
"c.properties",
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
),
]);
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
world(&[(
"glass.properties",
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
)])
};
// A mirrored north face (a vanilla pane's): its texture's right is east.
let mirrored = || {
Some(Axes {
right: [1, 0, 0],
up: [0, 1, 0],
})
};
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
let at = |ctm: &ConnectedTextures| {
ctm.pick(
1,
3,
Face::North,
[0, 64, 0],
east,
|| None,
mirrored,
|_| {},
)
};
// Glass to the east is to the texture's right: the row's left end.
assert_eq!(at(&rule("texture")), tile(1000));
// By the cube's directions it is to the left: the row's right end.
assert_eq!(at(&rule("none")), tile(1002));
// A plain overlay keeps the standard directions: glass north of a top face is above it
// (tile 15) whatever the quad's texture says.
let overlay = world(&[(
"a.properties",
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
)]);
let turned = || {
Some(Axes {
right: [0, 0, 1],
up: [1, 0, 0],
})
};
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
let mut got = Vec::new();
overlay.pick(
2,
7,
Face::Up,
[0, 64, 0],
north,
|| None,
turned,
|o| got.push(o.layer),
);
assert_eq!(got, [1015]);
}
#[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,9 +3,8 @@
//! 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,
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+7 -2
View File
@@ -212,7 +212,7 @@ fn operating_system() -> String {
} else if cfg!(target_os = "macos") {
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
} else if cfg!(windows) {
horizon_telemetry::system::windows_version()
command_output("cmd", &["/C", "ver"])
} else {
None
};
@@ -227,7 +227,12 @@ 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| horizon_telemetry::system::linux_cpu_name(&t))
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())
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
+3 -13
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/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` 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/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/` 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" | "data_maps" => kind,
| "villager_trade" | "dialog" => 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,7 +623,6 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -883,15 +882,6 @@ 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,8 +13,7 @@ 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. The shaders read it as a flat
/// `array<vec4<f32>, 32>` (nested arrays need ARB_arrays_of_arrays, absent from GL below 4.3).
/// Camera-relative position and radius; RGB intensity in a second vec4.
pub type Colored = [[f32; 4]; 2];
/// Nearest validated native visual lights; never enters gameplay lighting.
@@ -76,12 +75,6 @@ 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())
@@ -125,23 +118,6 @@ 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(
+18 -74
View File
@@ -22,18 +22,12 @@ 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<vec4<f32>, 32>,
colored_lights: array<array<vec4<f32>, 2>, 16>,
colored_count: vec4<u32>,
emissive_layers: array<vec4<u32>, 128>,
emissive_layers: array<vec4<u32>, 32>,
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>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -64,16 +58,13 @@ 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), or the
// dissolve mask's atlas UV for overlay 38.
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
@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,
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -106,22 +97,12 @@ 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 * 2u];
let color = globals.colored_lights[i * 2u + 1u];
let at = globals.colored_lights[i][0];
let color = globals.colored_lights[i][1];
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -135,7 +116,6 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -272,15 +252,7 @@ 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));
}
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;
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
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.
@@ -343,34 +315,8 @@ 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; }
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
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, hdr_scene_alpha(in.scene));
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);
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -388,21 +334,19 @@ 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. 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.
// 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.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
// 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 tier = select(in.tier, 4u, in.tier == 0u);
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
// 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))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
+18 -42
View File
@@ -5,7 +5,6 @@
use crate::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
camera::NearPlane,
environment::Sky,
world::World,
};
@@ -136,24 +135,9 @@ fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option
};
Some((s.fluid, height))
}
/// `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 {
/// `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 {
let block = |p: [f64; 3]| {
[
p[0].floor() as i32,
@@ -162,19 +146,28 @@ fn camera_fog_type(
]
};
let b = block(camera);
if let Some((Fluid::Water, h)) = fluid(b)
if let Some((Fluid::Water, h)) = camera_fluid_height(world, facts, b)
&& camera[1] < f64::from(b[1]) + h
{
return FogType::Water;
}
for point in near.points() {
let p: [f64; 3] = std::array::from_fn(|i| camera[i] + point[i]);
for p in [
[
camera[0] + forward[0] * 0.05,
camera[1] + forward[1] * 0.05,
camera[2] + forward[2] * 0.05,
],
camera,
] {
let b = block(p);
match fluid(b) {
match camera_fluid_height(world, facts, b) {
Some((Fluid::Lava, h)) if p[1] <= f64::from(b[1]) + h => return FogType::Lava,
Some((Fluid::Lava, _)) => {}
_ if powder_snow(b) => return FogType::PowderSnow,
_ => {}
_ => {
if &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow" {
return FogType::PowderSnow;
}
}
}
}
FogType::Atmospheric
@@ -455,23 +448,6 @@ 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 {
-10
View File
@@ -947,16 +947,6 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
+19 -368
View File
@@ -4,7 +4,7 @@
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
//! page of each and draws in a few multi-draws.
use std::collections::{BTreeMap, VecDeque};
use std::collections::BTreeMap;
/// Ranges are rounded up to this many vertices to limit fragmentation, and
/// start at multiples of it (`flat` names sections by these blocks).
@@ -100,9 +100,6 @@ 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.
@@ -142,10 +139,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`. 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`).
/// 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`.
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)
@@ -244,7 +241,7 @@ impl Arena {
})
}
/// The rounded length a request of `vertices` occupies.
pub fn span(vertices: u64) -> u64 {
fn span(vertices: u64) -> u64 {
vertices.max(1).next_multiple_of(GRAIN)
}
/// Bytes of every page's buffer.
@@ -255,18 +252,6 @@ 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)
}
@@ -281,15 +266,6 @@ 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
@@ -414,49 +390,6 @@ 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);
@@ -545,17 +478,14 @@ impl Arena {
old.free.give(previous, capacity - previous);
true
}
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
/// the buffers of the pages it emptied, for a [`Graveyard`].
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
let mut released = Vec::new();
/// Reclaim after an upload batch, so replacing a section can reuse its range.
pub fn trim(&mut self) {
for page in &mut self.pages {
if page
.as_ref()
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
&& let Some(p) = page.take()
if let Some(p) = page
&& p.free.0.len() == 1
&& p.free.0.get(&0) == Some(&p.capacity)
{
released.push(p.buffer);
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
@@ -565,67 +495,13 @@ impl Arena {
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
}
released
}
/// Drops every page; gives back their buffers, for a [`Graveyard`].
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
}
}
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
/// thread holds its last reference.
const GRAVE_FRAMES: usize = 4;
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
pub struct Graveyard<T> {
graves: VecDeque<Vec<T>>,
}
impl<T> Default for Graveyard<T> {
fn default() -> Self {
Self {
graves: VecDeque::new(),
}
}
}
impl<T> Graveyard<T> {
/// Released this frame.
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
if self.graves.is_empty() {
self.graves.push_back(Vec::new());
}
if let Some(grave) = self.graves.back_mut() {
grave.extend(items);
}
}
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
pub fn advance(&mut self) -> Vec<T> {
self.graves.push_back(Vec::new());
if self.graves.len() > GRAVE_FRAMES {
self.graves.pop_front().unwrap_or_default()
} else {
Vec::new()
}
}
}
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
/// unloaded chunks; should the thread not start, they drop here.
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
if items.is_empty() {
return;
}
let _ = std::thread::Builder::new()
.name("Buffer freeing".into())
.spawn(move || drop(items));
}
/// `create_buffer` with an out-of-memory failure returned instead of raised
/// (raised errors are fatal).
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
@@ -644,31 +520,13 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
mod tests {
use super::*;
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
#[test]
fn graveyard_frees_after_its_frames() {
let mut graves = Graveyard::default();
graves.add([1, 2]);
let mut freed = Vec::new();
for frame in 0..GRAVE_FRAMES + 2 {
if frame == 1 {
graves.add([3]);
}
freed.push(graves.advance());
}
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
expected[GRAVE_FRAMES - 1] = vec![1, 2];
expected[GRAVE_FRAMES] = vec![3];
assert_eq!(freed, expected);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
device_with(wgpu::Features::empty())
}
/// A device with those of `features` the adapter has.
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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;
@@ -682,7 +540,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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
@@ -820,7 +678,7 @@ mod tests {
/// `translucent_at` along.
#[test]
fn compaction_moves_what_sections_hold_in_the_section_arena() {
use super::super::{Part, SectionGpu, move_off_page};
use super::super::{SectionGpu, move_off_page};
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
@@ -836,7 +694,6 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -887,15 +744,14 @@ 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,
Part::Section { split },
&mut budget
split,
u64::MAX
));
queue.submit([encoder.finish()]);
let word =
@@ -928,32 +784,6 @@ 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 {
@@ -1086,185 +916,6 @@ 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,
list_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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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,10 +22,6 @@ 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,8 +173,7 @@ impl EntityOutline {
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
/// when there are any) and sizes the targets like the window.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
@@ -185,7 +184,6 @@ impl EntityOutline {
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.targets = None;
return;
}
let vertices = silhouettes.len() as u64;
+6 -106
View File
@@ -42,8 +42,6 @@ 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;
@@ -109,9 +107,6 @@ 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.
@@ -221,9 +216,7 @@ 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. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
/// pack's shadow camera's, with calls of their own.
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -231,20 +224,17 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
base: u64,
) -> Plan {
calls.clear();
ranges.clear();
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 = base;
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() {
if i == phase1_draws {
@@ -258,13 +248,7 @@ fn plan(
shadow_calls[k].get_or_insert(calls.len());
}
}
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);
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
match calls.last_mut() {
Some(last) if last.page == page && !starts => last.count += d[0],
_ => calls.push(Call {
@@ -299,12 +283,10 @@ 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: phase1_calls.unwrap_or(calls.len()).min(solid_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,
}
}
@@ -456,7 +438,6 @@ 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)
@@ -538,12 +519,9 @@ 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,
mut lists: Option<&mut super::gpu_lists::GpuLists>,
eye: Option<[f32; 3]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
@@ -581,13 +559,10 @@ impl Flat {
bytemuck::cast_slice(&self.origin_data),
);
}
// The GPU-made lists' rooms come first in the index list.
let room = lists.as_mut().map_or(0, |l| l.rooms());
let Plan {
phase1_calls,
solid_calls,
shadow_calls,
pass_calls,
total,
} = plan(
draws,
@@ -595,23 +570,19 @@ impl Flat {
phase1_draws,
solid_draws,
shadow,
passes,
self.range_align,
&mut self.calls,
&mut self.range_data,
&mut self.segments,
room,
);
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 {
@@ -663,9 +634,6 @@ impl Flat {
bytemuck::cast_slice(&self.phase2_call_data),
);
}
if let Some(lists) = &mut lists {
lists.prepare(device, encoder, uploads, &self.indices, eye);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
@@ -735,17 +703,13 @@ impl Flat {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
if let Some(lists) = &lists {
lists.dispatch(&mut pass);
}
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
for page in self.calls.iter().map(|c| c.page) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
@@ -786,27 +750,6 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
/// this frame's `prepare`) with a flat pipeline set.
pub fn draw_lists<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
lists: &super::gpu_lists::GpuLists,
group: u8,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
if !lists.prepared() {
return;
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in lists.calls().iter().enumerate() {
if call.group == group {
self.bind_page(pass, call.page, &vertices);
lists.draw_indexed(pass, k);
}
}
}
/// The camera's translucent calls.
pub fn translucent_calls(&self) -> &[Call] {
&self.calls[self.solid_calls..self.shadow_calls[0]]
@@ -927,12 +870,10 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
@@ -963,40 +904,6 @@ 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,
0,
);
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]
@@ -1012,12 +919,10 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
let call = |page, first, count| Call { page, first, count };
@@ -1041,12 +946,10 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
4,
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(
plan,
@@ -1054,7 +957,6 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
total: 6 * 13
}
);
@@ -1080,7 +982,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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
@@ -1159,12 +1061,10 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
align,
&mut calls,
&mut ranges,
&mut segments,
0,
);
// Phase 2's draws as the test leaves them: instance counts of 0,
// 1 where it saw the section.
@@ -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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -1,639 +0,0 @@
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
//!
//! Each frame the render thread walked every section phase 1 draws through
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
//! and pushed one draw per run. Here it pushes one record per section instead:
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
//! mesh), the call each of its parts draws in and its camera-relative origin. The
//! GPU finds the same runs and writes their indices, per call (one per pass
//! group and arena page), into the flat draws' index list, drawn with
//! `draw_indexed_indirect`. Calls take room for all the quads of their sections'
//! parts; the GPU counts the indices it wrote.
//!
//! Solid draws write depth and are tested against it, so their order within a
//! call does not change the picture.
use crate::mesher::{Part, Sides};
/// Words per row: first vertex, `Sides::gpu_row`, padding.
const ROW: usize = 24;
/// Most workgroups along one dimension of a dispatch.
const GROUP_LIMIT: u32 = 65535;
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
fn part_code(part: Part) -> u32 {
match part {
Part::All => 0,
Part::Opaque => 1,
Part::Cutout => 2,
}
}
/// One of the frame's calls: what pass `group` draws of arena `page`, from
/// index `first` with room for `count`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ListCall {
pub group: u8,
pub page: u32,
pub first: u32,
pub count: u32,
}
pub(super) struct GpuLists {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
rows: wgpu::Buffer,
row_capacity: u64,
free_ids: Vec<u32>,
next_id: u32,
/// The frame's sections: id, the call and part of each of its parts, its
/// number of parts; then its origin.
records: Vec<[u32; 8]>,
record_buffer: wgpu::Buffer,
record_capacity: u64,
calls: Vec<ListCall>,
/// `calls`' index by page and group in the band of distance the last
/// section listed is in (`u32::MAX`: none yet).
call_of: Vec<u32>,
band: u32,
call_data: Vec<[u32; 5]>,
/// Per call `DrawIndexedIndirectArgs`; the GPU adds what it writes to the count.
call_buffer: wgpu::Buffer,
call_capacity: u64,
view: wgpu::Buffer,
group: Option<(wgpu::BindGroup, u32)>,
/// The indices the frame's calls take, and the most they may.
room: u64,
cap: u64,
}
fn buffer(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
// COPY_SRC: rows move to a larger buffer; tests read calls back.
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
impl GpuLists {
pub fn new(device: &wgpu::Device) -> Self {
let read = wgpu::BufferBindingType::Storage { read_only: true };
let write = wgpu::BufferBindingType::Storage { read_only: false };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Section lists"),
entries: &[
entry(0, read),
entry(1, read),
entry(2, wgpu::BufferBindingType::Uniform),
entry(3, write),
entry(4, write),
],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Section lists"),
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Section lists"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Section lists"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some("flatten_sides"),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE;
let indirect = wgpu::BufferUsages::INDIRECT | storage;
Self {
pipeline,
layout,
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
row_capacity: 1,
free_ids: Vec::new(),
next_id: 0,
records: Vec::new(),
record_buffer: buffer(device, "Sections listed", 32, storage),
record_capacity: 1,
calls: Vec::new(),
call_of: Vec::new(),
band: 0,
call_data: Vec::new(),
call_buffer: buffer(device, "Section list calls", 20, indirect),
call_capacity: 1,
view: buffer(
device,
"Section lists' eye",
16,
wgpu::BufferUsages::UNIFORM,
),
group: None,
room: 0,
cap: 0,
}
}
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
pub fn add(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
start: u32,
sides: &Sides,
) -> u32 {
let id = self.free_ids.pop().unwrap_or_else(|| {
self.next_id += 1;
self.next_id - 1
});
self.set(device, encoder, uploads, id, start, sides);
id
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
}
/// Mesh `id` now starts at vertex `start` (moved to another page).
pub fn set(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
start: u32,
sides: &Sides,
) {
let need = u64::from(id) + 1;
if need > self.row_capacity {
let capacity = need.next_power_of_two().max(1024);
let rows = buffer(
device,
"Section rows",
capacity * ROW as u64 * 4,
wgpu::BufferUsages::STORAGE,
);
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
encoder.copy_buffer_to_buffer(
&self.rows,
0,
&rows,
0,
self.row_capacity * ROW as u64 * 4,
);
self.rows = rows;
self.row_capacity = capacity;
}
let mut row = [0u32; ROW];
row[0] = start;
row[1..23].copy_from_slice(&sides.gpu_row());
uploads.buffer(
encoder,
&self.rows,
u64::from(id) * ROW as u64 * 4,
bytemuck::cast_slice(&row),
);
}
/// Starts the frame's lists.
pub fn clear(&mut self, device: &wgpu::Device) {
self.records.clear();
self.calls.clear();
self.call_of.fill(u32::MAX);
self.band = 0;
self.room = 0;
self.group = None;
// A quarter of the binding they share with the CPU's draws.
self.cap = device.limits().max_storage_buffer_binding_size / 16;
}
/// Section `id`, in `page`, at `origin` from the camera and `distance`
/// (squared, as bits) from it: each of `parts` (pass group, part) draws the
/// quads of the part that face the eye. False, listing nothing, when the
/// frame's lists hold all the room they may: the caller draws it.
///
/// Sections come nearest first. Each band of distance gets calls of its own,
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
/// a call the GPU writes sections in no set order, and cutout quads, which
/// tile GPUs can't hide before shading, then draw mostly front to back.
pub fn list(
&mut self,
id: u32,
page: u32,
distance: u64,
sides: &Sides,
parts: &[(u8, Part)],
origin: [f32; 3],
) -> bool {
let parts = &parts[..parts.len().min(2)];
let mut counts = [0; 2];
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
*count = sides.part_quads(part) * 6;
}
let room = u64::from(counts[0]) + u64::from(counts[1]);
if self.room + room > self.cap {
return false;
}
self.room += room;
let band = super::page_runs::band(distance) as u32;
if band != self.band {
self.band = band;
self.call_of.fill(u32::MAX);
}
let mut record = [id, 0, 0, parts.len() as u32, 0, 0, 0, 0];
for (k, &(group, part)) in parts.iter().enumerate() {
let key = page as usize * 2 + usize::from(group.min(1));
if self.call_of.len() <= key {
self.call_of.resize(key + 1, u32::MAX);
}
if self.call_of[key] == u32::MAX {
self.call_of[key] = self.calls.len() as u32;
self.calls.push(ListCall {
group,
page,
first: 0,
count: 0,
});
}
let call = self.call_of[key];
self.calls[call as usize].count += counts[k];
record[1 + k] = call | part_code(part) << 28;
}
for (to, o) in record[4..7].iter_mut().zip(origin) {
*to = o.to_bits();
}
self.records.push(record);
true
}
/// The sections listed this frame.
pub fn listed(&self) -> usize {
self.records.len()
}
/// The frame's calls, with their rooms once laid out (`rooms`).
pub fn calls(&self) -> &[ListCall] {
&self.calls
}
/// Lays the calls' rooms out one after the other from the start of the
/// index list: the indices they take.
pub fn rooms(&mut self) -> u64 {
let mut room = 0u64;
for call in &mut self.calls {
call.first = room as u32;
room += u64::from(call.count);
}
room
}
/// Stages the frame's sections, calls and eye (camera-relative; none: every
/// quad faces it), and binds them for `dispatch` writing into `indices`.
/// After `rooms`.
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
indices: &wgpu::Buffer,
eye: Option<[f32; 3]>,
) {
self.group = None;
let records = self.records.len() as u64;
if records == 0 {
return;
}
if records > self.record_capacity {
self.record_capacity = records.next_power_of_two();
self.record_buffer = buffer(
device,
"Sections listed",
self.record_capacity * 32,
wgpu::BufferUsages::STORAGE,
);
}
uploads.buffer(
encoder,
&self.record_buffer,
0,
bytemuck::cast_slice(&self.records),
);
self.call_data.clear();
self.call_data
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
let calls = self.call_data.len() as u64;
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
if calls > self.call_capacity {
self.call_capacity = calls.next_power_of_two();
self.call_buffer = buffer(
device,
"Section list calls",
self.call_capacity * 20,
indirect,
);
}
uploads.buffer(
encoder,
&self.call_buffer,
0,
bytemuck::cast_slice(&self.call_data),
);
let [x, y, z] = eye.unwrap_or_default();
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section lists"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.record_buffer,
offset: 0,
size: wgpu::BufferSize::new(records * 32),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.rows.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.view.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.call_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: indices.as_entire_binding(),
},
],
});
self.group = Some((group, records as u32));
}
/// Writes the frame's lists (after `prepare`).
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
let Some((group, records)) = &self.group else {
return;
};
let groups = *records;
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
}
/// Whether `dispatch` writes this frame's lists.
pub fn prepared(&self) -> bool {
self.group.is_some()
}
/// Draws call `k` with a pipeline, its page's vertices and the index list bound.
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::{Part, Sides};
use wgpu::util::DeviceExt;
struct Section {
sides: Sides,
origin: [f32; 3],
start: u32,
page: u32,
parts: &'static [(u8, Part)],
}
/// Random sorts: some parts empty, grouped or not, split or not, planes in
/// and out of a section, each listed in one of the three ways it can be.
fn sections(count: usize) -> Vec<Section> {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: u32| {
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((seed >> 33) % u64::from(n)) as u32
};
(0..count)
.map(|_| {
let mut ends = [0; 14];
let mut end = 0;
for e in &mut ends {
if next(3) > 0 {
end += next(40);
}
*e = end;
}
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
Section {
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
start: next(10_000) * 4,
page: next(3),
parts: match next(3) {
0 => &[(0, Part::All)],
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
_ => &[(1, Part::All)],
},
}
})
.collect()
}
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let draw = |first, quads| runs.push((first, quads));
match eye {
Some(eye) => {
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
s.sides.visible_from(eye, eye[1], part, draw);
}
None => s.sides.every(part, draw),
}
runs
}
/// Lists `sections` from each of a few eyes; per eye, the lists, the
/// calls' words and the indices written.
fn run(sections: &[Section]) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
let (device, queue) = device()?;
let mut results = Vec::new();
for eye in [
Some([3.5f32, 70.25, -12.0]),
Some([-40.0, -3.0, 50.5]),
None,
] {
let mut lists = GpuLists::new(&device);
lists.clear(&device);
let mut rows = vec![0u32; sections.len() * ROW];
for (id, s) in sections.iter().enumerate() {
rows[id * ROW] = s.start;
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
// Nearest first, over several bands.
let distance = (id as f64 * 2.0).powi(2).to_bits();
assert!(lists.list(id as u32, s.page, distance, &s.sides, s.parts, s.origin));
}
let room = lists.rooms();
let init = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
lists.rows = init(bytemuck::cast_slice(&rows), storage);
let indices = init(&vec![0xffu8; room as usize * 4], storage);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
lists.prepare(&device, &mut encoder, &mut uploads, &indices, eye);
uploads.finish(&encoder);
{
let mut pass = encoder.begin_compute_pass(&Default::default());
lists.dispatch(&mut pass);
}
queue.submit([encoder.finish()]);
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: words * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
};
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
let words = read_back(&indices, room);
results.push((eye, lists, calls, words));
}
Some(results)
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return None;
};
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
if device.is_none() {
eprintln!("no GPU device: skipped");
}
device
}
/// The call section `i`'s `k`th part draws in, as listed.
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
}
/// Each band of distance and page gets a pass's calls of its own, in the
/// order sections come (nearest first), and sections of one share them.
#[test]
fn calls_go_by_band_then_page_nearest_first() {
let Some((device, _)) = device() else {
return;
};
let mut lists = GpuLists::new(&device);
lists.clear(&device);
let sides = Sides::unsorted(4);
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
let distance = f64::powi(blocks, 2).to_bits();
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3]));
}
let calls: Vec<_> = lists
.calls()
.iter()
.map(|c| (c.group, c.page, c.count))
.collect();
assert_eq!(
calls,
[
(0, 1, 48),
(1, 1, 48),
(0, 0, 24),
(1, 0, 24),
(0, 1, 24),
(1, 1, 24),
(0, 0, 24),
(1, 0, 24)
]
);
}
/// `flatten_sides` writes, in each call's room, the indices of exactly the
/// runs `Sides::visible_from` finds for each listed section's parts (in
/// some order: workgroups take room as they come), and counts them.
#[test]
fn flatten_sides_writes_the_runs_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(&sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
for k in 0..quads * 6 {
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
}
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s index count, eye {eye:?}"
);
assert_eq!(calls[k * 5 + 2], call.first);
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
}
}
}
}
@@ -1,178 +0,0 @@
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
// listed, the runs of its quads that face the eye, found as
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
// each of its parts draws in (one per pass group and arena page) after taking
// room there: their indices, in the flat draws' index list, as `flatten_seen`
// does for occlusion culling's phase 2. Solid draws write depth and are tested
// against it, so their order within a call does not change the picture.
struct View {
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
// without, every bucket is drawn (`Sides::every`).
eye: vec3<f32>,
has_eye: f32,
}
// Per section listed: its row's id; for each of its parts (up to two) the call
// it draws in, with the part (`PART_*`) in the top bits; its number of parts.
// Then its origin relative to the camera.
struct Record {
words: vec4<u32>,
origin: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> records: array<Record>;
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
// (14 ends, 7 planes' bits, flags).
@group(0) @binding(1) var<storage, read> rows: array<u32>;
@group(0) @binding(2) var<uniform> view: View;
// Per call `DrawIndexedIndirectArgs`: its index count starts at 0, its first
// index is where its room starts.
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
// The flat draws' index list.
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
const ROW: u32 = 24u;
const LANES: u32 = 64u;
const PART_ALL: u32 = 0u;
const PART_OPAQUE: u32 = 1u;
const PART_CUTOUT: u32 = 2u;
const CALL_MASK: u32 = 0x0fffffffu;
// `sides::MARGIN`.
const MARGIN: f32 = 0.0625;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
fn plane(row: u32, bucket: u32) -> f32 {
return bitcast<f32>(rows[row + 15u + bucket]);
}
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
var faces = true;
if view.has_eye != 0.0 {
switch bucket {
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
default: {}
}
}
return faces;
}
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
// out and there are 14.
struct Runs {
n: u32,
quads: u32,
runs: array<vec2<u32>, 7>,
}
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
// the section): empty parts do not break a run.
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
var r: Runs;
let grouped = (rows[row + 22u] & 1u) != 0u;
let split = (rows[row + 22u] & 2u) != 0u;
var start = 0u;
var open = false;
var first = 0u;
for (var n = 0u; n < 14u; n++) {
let end = rows[row + 1u + n];
if end == start {
continue;
}
var bucket: u32;
var cutout: bool;
if grouped {
bucket = n % 7u;
cutout = n >= 7u;
} else {
bucket = n / 2u;
cutout = n % 2u == 1u;
}
let wanted = part == PART_ALL || !split
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
let take = wanted && seen(row, bucket, eye);
if take && !open {
open = true;
first = start;
} else if !take && open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
open = false;
}
start = end;
}
if open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
}
return r;
}
fn record_index(group: vec3<u32>) -> u32 {
return group.y * 65535u + group.x;
}
// The eye relative to `record`'s section.
fn eye_of(record: Record) -> vec3<f32> {
return view.eye - record.origin.xyz;
}
fn part_word(record: Record, p: u32) -> u32 {
return select(record.words.z, record.words.y, p == 0u);
}
// Lane 0's runs per part, and where each part's indices start.
var<workgroup> found: array<Runs, 2>;
var<workgroup> at: array<u32, 2>;
// One workgroup per record: lane 0 finds the runs and takes room for their
// indices, then every lane writes them.
@compute @workgroup_size(64)
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group);
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w;
if lane == 0u {
let eye = eye_of(record);
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
let call = (word & CALL_MASK) * 5u;
var begin = 0u;
if runs.quads > 0u {
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
}
found[p] = runs;
at[p] = begin;
}
}
workgroupBarrier();
let base = rows[row];
for (var p = 0u; p < parts; p++) {
var to = at[p];
for (var k = 0u; k < found[p].n; k++) {
let run = found[p].runs[k];
let first = base + run.x * 4u;
let count = run.y * 6u;
for (var i = lane; i < count; i += LANES) {
out[to + i] = first + quad(i);
}
to += count;
}
}
}
+10 -54
View File
@@ -61,8 +61,7 @@ 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), the
/// texel's own brightness before lighting.
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub share: f32,
@@ -149,9 +148,6 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -272,7 +268,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 64,
size: 96,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -296,7 +292,6 @@ impl Hdr {
tiers: TIERS,
ui: 1.0,
masks: MASKS,
lighting: true,
calibration: None,
}
}
@@ -324,10 +319,7 @@ impl Hdr {
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture(
"Horizon HDR world",
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
@@ -445,19 +437,7 @@ 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,
@@ -530,10 +510,10 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 16];
let mut params = [0.0; 24];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -543,17 +523,14 @@ impl Hdr {
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
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[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[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
}
@@ -627,12 +604,6 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -685,18 +656,3 @@ mod tests {
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
#[cfg(test)]
mod lighting_tests {
use super::scene_gain;
#[test]
fn the_lighting_scales_with_the_headroom() {
// Enough headroom: the full strength, whatever the paper white.
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
// Little headroom, and none.
assert_eq!(scene_gain(600.0, 400.0), 2.625);
assert_eq!(scene_gain(200.0, 480.0), 1.0);
}
}
@@ -1,159 +0,0 @@
//! 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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return;
@@ -276,10 +276,6 @@ 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
@@ -39,8 +39,6 @@ struct HorizonMaterialOut {
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
// terrain.wgsl's `emission`, for HDR output's mark.
@location(11) @interpolate(flat) hdr_code: u32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
@@ -69,9 +67,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.fluid_quad = u32(fluid);
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
out.hdr_code = 15u - (flags & 15u);
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
let tier = 15u - (flags & 15u);
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@@ -149,8 +145,7 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position))).color;
in.face, fluid, in.emission, normal, tangent)).color;
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -159,33 +154,15 @@ struct HorizonMaterialSolid {
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
if in.hdr_code > 0u {
let whole = in.hdr_code > 4u;
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(horizon_terrain_emission_thresholds,
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
let y = horizon_terrain_brightness(texel.rgb);
if whole || y >= t.r {
return horizon_terrain_hdr_mark(tier, y);
}
}
return 1.0;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
let color = horizon_material_color(in, false);
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if horizon_surface.w == 0.0 {
set_surface(horizon_material_face_normal, 0.0, 0.0);
}
var out: HorizonMaterialSolid;
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
return out;
}
@@ -21,8 +21,6 @@ pub struct NativePack {
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// `[[particle_lights]]`.
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -135,7 +133,6 @@ impl NativePack {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
particle_lights: report.manifest.particle_lights.clone(),
frame,
sky,
material,
@@ -1294,7 +1294,7 @@ mod tests {
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -6,23 +6,7 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
/// Install trait mod renderers in dispatch order, once per process.
pub fn install(
renderers: Box<[&'static dyn RenderHooks]>,
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
RENDERERS.set(renderers)
}
use std::sync::atomic::{AtomicBool, Ordering};
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
@@ -34,16 +18,12 @@ pub fn set_mods_built_in(on: bool) {
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
|| RENDERERS
.get()
.is_some_and(|renderers| !renderers.is_empty())
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
@@ -79,7 +59,6 @@ pub struct WorldDrawn<'a> {
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
@@ -91,13 +70,13 @@ pub struct RenderNeeds {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
let _ = world;
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
let _ = needs;
}
/// What the mods need this frame ([`native_render_needs`]).
@@ -106,34 +85,3 @@ pub fn render_needs() -> RenderNeeds {
native_render_needs(&mut needs);
needs
}
#[cfg(test)]
mod tests {
use super::*;
struct Depth;
impl RenderHooks for Depth {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.world_depth = true;
}
}
struct Spans;
impl RenderHooks for Spans {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.entity_spans = event.needs.world_depth;
}
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
assert_eq!(
needs,
RenderNeeds {
world_depth: true,
entity_spans: true
}
);
}
}
@@ -1,36 +0,0 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
@@ -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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -1,296 +0,0 @@
//! `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));
}
}
@@ -319,7 +319,6 @@ impl PackEntities {
entities: &[Vertex],
spans: &[EntitySpan],
drawn: [bool; PROGRAMS],
crack: ([f32; 4], [u32; 2]),
shadow: Casters,
rest: &mut Vec<Vertex>,
) {
@@ -331,9 +330,7 @@ impl PackEntities {
rest.extend_from_slice(&entities[..first.min(entities.len())]);
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let program = program_of(&span).filter(|_| plain(run));
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
@@ -344,9 +341,6 @@ impl PackEntities {
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
_ => (span.id, 0),
@@ -425,8 +419,7 @@ pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
pub const PROGRAMS: usize = 4;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -447,8 +440,6 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
BLOCK
} else if span.stage == render_stage("PARTICLES") {
PARTICLE
} else if span.stage == render_stage("DESTROY") {
DESTROY
} else {
return None;
})
@@ -470,16 +461,6 @@ pub fn runs(
mod tests {
use super::*;
#[test]
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
let destroy = EntitySpan {
stage: render_stage("DESTROY"),
..Default::default()
};
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
}
#[test]
fn runs_split_the_list_between_pack_and_horizon() {
let v = |layer| Vertex {
@@ -502,21 +483,15 @@ mod tests {
let all = [true; PROGRAMS];
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
let shadow = EntitySpan {
translucent: true,
..spans[0]
};
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
// A beacon beam's quads stay Horizon's.
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
@@ -548,53 +523,3 @@ mod tests {
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
}
}
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
for quad in vertices.as_chunks_mut::<4>().0 {
let origin = [
(quad[0].light & 65535) as f32 / 65535.0,
(quad[0].light >> 16) as f32 / 65535.0,
];
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
let min = [0, 1]
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
for v in quad {
v.uv =
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
v.layer = 0;
v.light = 0x0000_f0f0;
}
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
#[test]
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
.into_iter()
.map(|uv| Vertex {
uv,
layer: 11,
light: crate::breaking::stage_light([0.25, 0.5]),
..bytemuck::Zeroable::zeroed()
})
.collect();
project_cracks(
&mut vertices,
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
[4096, 9216],
);
assert!(
vertices
.iter()
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
);
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
}
}
@@ -408,7 +408,6 @@ pub(super) struct World<'a> {
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
pub gui_size: [u32; 2],
/// Set 1 of the clouds' draw: their offset and palette.
pub clouds: Option<&'a wgpu::BindGroupLayout>,
/// The game can hand its rain and snow to the pack.
@@ -511,11 +510,8 @@ pub struct PackPost {
/// with the page buffer it was made for.
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
gui_size: [u32; 2],
/// 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>,
@@ -1343,16 +1339,11 @@ fn build_pass(
} else if let Some(&buffer) = p.buffers.get(l) {
let format = formats[buffer as usize];
let mut state = target(format);
if let (Some(t), Some(b)) = (
&mut state,
p.buffer_blends
.iter()
.find(|(n, _)| *n == buffer)
.map_or(p.blend, |(_, b)| *b),
) && format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
if let (Some(t), Some(b)) = (&mut state, p.blend)
&& format
.guaranteed_format_features(features)
.flags
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
{
t.blend = Some(blend_state(b));
}
@@ -1440,15 +1431,7 @@ fn build_pass(
depth_write_enabled: Some(g.depth_write),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: if p.name == "gbuffers_damagedblock" {
wgpu::DepthBiasState {
constant: -10,
slope_scale: -1.0,
clamp: 0.0,
}
} else {
Default::default()
},
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
@@ -1908,15 +1891,8 @@ impl PackPost {
post.block,
post.entities_translucent,
post.particles,
post.damaged_block,
];
let late = [
false,
false,
post.translucent_late,
post.particles_late,
false,
];
let late = [false, false, post.translucent_late, post.particles_late];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
let entity_vertices = (programs.iter().chain(&shadow_programs))
@@ -1932,21 +1908,8 @@ impl PackPost {
cull: false,
});
let entity_passes = programs.map(|p| {
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
if p.name == "gbuffers_damagedblock" {
let decal = Geometry {
buffers: g.buffers,
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
ids: g.ids,
cull: false,
};
build(p, Some(&decal))
} else {
build(p, Some(g))
}
})
p.zip(entity_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
});
let shadow_entities = shadow_programs.map(|p| {
p.zip(entity_geometry.as_ref())
@@ -2278,9 +2241,7 @@ impl PackPost {
draws,
block_ids,
page_groups: HashMap::new(),
gui_size: world.gui_size,
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2779,13 +2740,6 @@ 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;
}
@@ -2904,7 +2858,6 @@ impl PackPost {
queue: &wgpu::Queue,
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
rest: &mut Vec<super::Vertex>,
) {
let shadow = self.shadow_casters();
@@ -2921,7 +2874,6 @@ impl PackPost {
entities,
spans,
drawn,
(crumbling, self.gui_size),
shadow,
rest,
);
@@ -2944,13 +2896,6 @@ 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()
@@ -3668,10 +3613,6 @@ 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 {
@@ -3910,10 +3851,6 @@ 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()
@@ -4012,7 +3949,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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
@@ -51,12 +51,6 @@ 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
@@ -70,35 +64,6 @@ 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 {
@@ -233,26 +198,6 @@ 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",
@@ -275,9 +220,6 @@ 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,
}
@@ -293,8 +235,6 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -404,9 +344,9 @@ impl FrameUniforms {
frame.depth_far,
);
let camera = frame.camera.as_vec3();
let first_frame = self.previous.is_none();
let (previous_exact, previous_view, previous_projection) =
self.previous.unwrap_or((frame.camera, view, projection));
self.previous
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
let previous_camera = previous_exact.as_vec3();
self.previous = Some((frame.camera, view, projection));
// The camera's whole blocks and the rest, exact however far from the origin.
@@ -436,22 +376,9 @@ impl FrameUniforms {
};
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
if first_frame {
self.wetness = inputs.rain;
self.eye_smooth = eye;
}
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 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.
@@ -497,8 +424,6 @@ 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]),
@@ -558,7 +483,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
_ => Value::i(&[0]),
}
};
self.values.clear();
@@ -604,46 +529,6 @@ 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 {
@@ -662,8 +547,7 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor"
| "endFlashPosition" => 3,
| "skyColor" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -692,77 +576,6 @@ 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);
@@ -853,147 +666,3 @@ mod tests {
);
}
}
#[cfg(test)]
mod initial_history_tests {
use super::*;
use crate::hud::HudBatch;
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
match u.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
}
}
#[test]
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
let mut u = FrameUniforms::default();
f.camera = DVec3::new(1024.25, -3.5, 11.75);
f.yaw = 21.0;
f.pitch = -16.0;
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
for (prior, current) in [
("gbufferPreviousProjection", "gbufferProjection"),
("gbufferPreviousModelView", "gbufferModelView"),
] {
assert_eq!(float(&u, prior), float(&u, current));
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
}
let camera = float(&u, "cameraPosition");
let view = float(&u, "gbufferModelView");
let projection = float(&u, "gbufferProjection");
f.camera = DVec3::new(-100.0, 20.0, 40.0);
f.yaw = 90.0;
f.pitch = 50.0;
f.fov_degrees = 100.0;
u.update(&f, &PackInputs::default(), [1000, 500]);
assert_eq!(float(&u, "previousCameraPosition"), camera);
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
assert_ne!(
float(&u, "previousCameraPosition"),
float(&u, "cameraPosition")
);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let mut input = PackInputs {
rain: 0.75,
eye_light: [3, 14],
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert_eq!(float(&u, "wetness")[0], 0.75);
match u.values[index("eyeBrightnessSmooth").unwrap()] {
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
_ => panic!("integer uniform expected"),
}
input.rain = 0.0;
input.eye_light = [0, 0];
u.update(&f, &input, [800, 450]);
assert!(float(&u, "wetness")[0] > 0.0);
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
}
fn frame(hud: &HudBatch) -> Frame<'_> {
Frame {
eye: glam::DVec3::ZERO,
eye_rotation: [0.0; 2],
first_person: true,
shadow_scene_revision: (0, 0),
shadow_cache: false,
water_plane: None,
weather: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: glam::DVec3::new(0.5, 100.0, 0.5),
yaw: 0.0,
pitch: 10.0,
fov_degrees: 85.0,
bob: glam::Mat4::IDENTITY,
spin: glam::Mat4::IDENTITY,
sky: crate::environment::Sky::default(),
distance: 12.0,
detail_distance: 12.0,
depth_far: 1000.0,
underwater: false,
cave_culling: false,
title: true,
sky_blocked: false,
skybox: false,
end_sky: false,
end_sky_sprite: None,
environment_fog: None,
water_fog: [0.0; 3],
fog: None,
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_motion: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
additive: &[],
see_through: &[],
celestial: &[],
stars: None,
hand: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
border: None,
game_time: 0.0,
shader_game_time: 0.0,
end_portal_sprites: [[0.0; 4]; 2],
crumbling_sprite: [0.0; 4],
ticks: 0,
darkness: (0.0, 0.0),
night_vision: 0.0,
block_flicker: 0.0,
boss_darken: 0.0,
menu_blur: None,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
}
}
}
@@ -16,8 +16,6 @@
/// 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)]
@@ -29,11 +27,6 @@ pub(super) struct Run {
pub tag: [u32; 3],
}
/// The band of a section `distance` from the camera (squared, as bits).
pub(super) fn band(distance: u64) -> u64 {
(f64::from_bits(distance).sqrt() / BAND) as u64
}
#[derive(Default)]
pub(super) struct PageRuns {
runs: Vec<Run>,
@@ -48,24 +41,10 @@ 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 = band(distance);
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
self.runs.push(Run {
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
key: band << 32 | u64::from(page),
start: start as u32,
len: len as u32,
tag,
@@ -82,8 +61,7 @@ impl PageRuns {
pages: &mut [(u32, Option<u32>)],
base: usize,
) -> &[Run] {
// Group and page: one of each needs no reordering.
let page = |r: &Run| r.key & (3 << GROUP_SHIFT | u64::from(u32::MAX));
let page = |r: &Run| r.key as u32;
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
return &self.runs;
}
@@ -179,24 +157,6 @@ 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)]);
+77 -267
View File
@@ -1,64 +1,36 @@
//! Vanilla 26.3's CubeMapTexture and CubeMap, separate from the pixel-filtered GUI atlas.
use crate::{Result, hud::decode_bytes};
use crate::{Result, hud::decode};
use glam::Mat4;
use std::{
path::Path,
sync::{Arc, Mutex},
};
use std::path::Path;
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,
/// 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>>>,
pub pixels: Vec<u8>,
}
impl Faces {
/// 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])
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),
}
})
}
/// 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> {
fn try_load(root: &Path) -> Result<Self> {
let background = root.join("assets/minecraft/textures/gui/title/background");
let mut size = 0;
let mut files = Vec::with_capacity(FACE_ORDER.len());
let mut texels = Vec::new();
let mut pixels = Vec::new();
for face in FACE_ORDER {
let (path, file, (w, h, data)) = load_face(root, defaults, face)?;
let path = background.join(format!("panorama_{face}.png"));
let (w, h, data) = decode(&path)?;
if w != h || (size != 0 && w != size) {
return Err(format!(
"{}: panorama faces must be equally sized squares",
@@ -69,104 +41,11 @@ 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() {
texels.extend_from_slice(row);
pixels.extend_from_slice(row);
}
files.push(file);
}
// Decoded once here to check them, and kept for the first cube.
Ok(Self {
size,
files,
colour: [0; 4],
decoded: Mutex::new(Some(texels)),
})
Ok(Self { size, pixels })
}
/// 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 {
@@ -183,20 +62,40 @@ fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
pub(super) struct Panorama {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
group: wgpu::BindGroup,
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: Arc<Faces>,
faces: &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"),
@@ -241,6 +140,24 @@ 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()),
@@ -286,18 +203,13 @@ impl Panorama {
});
Self {
pipeline,
layout,
sampler,
group,
uniform,
faces,
cube: None,
}
}
/// Ready a title screen frame: the cube, made if it is not on the GPU.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
&self,
queue: &wgpu::Queue,
width: u32,
height: u32,
@@ -309,68 +221,11 @@ 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, cube, &[]);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(0..36, 0..1);
}
}
@@ -398,18 +253,9 @@ 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-{}-{}",
@@ -418,6 +264,9 @@ 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 {
@@ -460,19 +309,16 @@ 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, &f.0).unwrap();
let faces = Faces::try_load(&f.0).unwrap();
assert_eq!(faces.size, 1024);
// 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);
assert_eq!(faces.pixels.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!(
again[at..at + 4],
faces.pixels[at..at + 4],
[source_face, (x % 251) as u8, ((1023 - y) % 253) as u8, 255]
);
}
@@ -483,57 +329,21 @@ 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, &f.0).unwrap().size, 1);
assert_eq!(Faces::try_load(&f.0).unwrap().size, 1);
f.write(5, 2, 1);
assert!(
Faces::try_load(&f.0, &f.0)
Faces::try_load(&f.0)
.unwrap_err()
.to_string()
.contains("equally sized squares")
);
let fallback = Faces::load(&f.0, &f.0);
assert_eq!(fallback.texels(), [96, 110, 111, 255].repeat(6));
let fallback = Faces::load(&f.0);
assert_eq!(fallback.pixels.len(), 24);
f.write(5, 2, 2);
assert!(Faces::try_load(&f.0, &f.0).is_err());
assert!(Faces::try_load(&f.0).is_err());
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
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));
assert!(Faces::try_load(&f.0).is_err());
assert_eq!(Faces::load(&f.0).size, 1);
}
#[test]
@@ -85,7 +85,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -1,181 +0,0 @@
//! Pipelines kept between the renderers built on one device. The start builds
//! three (the boot screen's, the title artwork's, the world's) and a resource
//! reload or a graphics option builds another; each compiled every shader
//! module and pipeline again, though most come out identical (the boot and
//! title renderers' entirely, the world's entity pipelines). On a driver that
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
//! of the main thread before the title showed.
//!
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
//! labels, sources, constants, formats and states, and the handles a descriptor
//! names (shader modules, layouts) by their ids, so a descriptor that names a
//! kept handle matches only the same handle. Kept objects stay alive, so their
//! ids are never reused while they are keys.
use std::{
collections::HashMap,
sync::{Mutex, PoisonError},
};
/// One map of kept objects with the generation each was last asked for in.
struct Kept<T> {
map: Mutex<HashMap<String, (T, u32)>>,
}
impl<T> Default for Kept<T> {
fn default() -> Self {
Self {
map: Mutex::new(HashMap::new()),
}
}
}
impl<T: Clone> Kept<T> {
/// The object kept under `key`, else `make`'s, kept. The lock is not
/// held while `make` compiles.
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
if let Some((object, used)) = self
.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_mut(&key)
{
*used = generation;
return object.clone();
}
let object = make();
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(key, (object.clone(), generation));
object
}
fn keep_only(&self, generation: u32) {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|_, (_, used)| *used == generation);
}
fn len(&self) -> usize {
self.map
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
}
/// The shader modules, layouts and pipelines of the device's renderers.
#[derive(Default)]
pub struct Reuse {
generation: std::sync::atomic::AtomicU32,
shaders: Kept<wgpu::ShaderModule>,
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
pipeline_layouts: Kept<wgpu::PipelineLayout>,
render: Kept<wgpu::RenderPipeline>,
}
impl Reuse {
/// A renderer starts building: what it asks for is kept, the rest is let
/// go by [`Reuse::finish`].
pub fn begin(&self) -> u32 {
self.generation
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.wrapping_add(1)
}
/// The renderer of `generation` is built: objects only older renderers
/// used are dropped here (the renderers still alive hold their own).
pub fn finish(&self, generation: u32) {
self.shaders.keep_only(generation);
self.bind_group_layouts.keep_only(generation);
self.pipeline_layouts.keep_only(generation);
self.render.keep_only(generation);
}
/// How many objects are kept.
pub fn len(&self) -> usize {
self.shaders.len()
+ self.bind_group_layouts.len()
+ self.pipeline_layouts.len()
+ self.render.len()
}
fn current(&self) -> u32 {
self.generation.load(std::sync::atomic::Ordering::Relaxed)
}
/// `device`'s `create_*` calls through this cache.
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
On {
reuse: self,
device,
generation: self.current(),
}
}
}
/// A device whose shader modules, layouts and render pipelines come from a
/// [`Reuse`] when one with the same descriptor is kept.
pub struct On<'a> {
reuse: &'a Reuse,
device: &'a wgpu::Device,
generation: u32,
}
impl On<'_> {
pub fn create_shader_module(
&self,
descriptor: wgpu::ShaderModuleDescriptor<'_>,
) -> wgpu::ShaderModule {
let key = format!("{descriptor:?}");
self.reuse.shaders.get(key, self.generation, || {
self.device.create_shader_module(descriptor)
})
}
pub fn create_bind_group_layout(
&self,
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
) -> wgpu::BindGroupLayout {
let key = format!("{descriptor:?}");
self.reuse.bind_group_layouts.get(key, self.generation, || {
self.device.create_bind_group_layout(descriptor)
})
}
pub fn create_pipeline_layout(
&self,
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
) -> wgpu::PipelineLayout {
let key = format!("{descriptor:?}");
self.reuse.pipeline_layouts.get(key, self.generation, || {
self.device.create_pipeline_layout(descriptor)
})
}
pub fn create_render_pipeline(
&self,
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
) -> wgpu::RenderPipeline {
let key = format!("{descriptor:?}");
self.reuse.render.get(key, self.generation, || {
self.device.create_render_pipeline(descriptor)
})
}
}
#[cfg(test)]
mod tests {
use super::Kept;
/// A key asked for again gets the kept object without making one; what
/// the latest generation did not ask for is let go when it finishes.
#[test]
fn kept_objects_are_shared_until_a_build_stops_asking() {
let kept = Kept::<u32>::default();
let made = std::cell::Cell::new(0);
let make = |value| {
made.set(made.get() + 1);
value
};
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
kept.keep_only(1);
assert_eq!(kept.len(), 2);
// The next build asks for "a" only.
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
kept.keep_only(2);
assert_eq!(kept.len(), 1);
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
assert_eq!(made.get(), 3);
}
}
+2 -16
View File
@@ -7,9 +7,8 @@
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
@@ -51,9 +50,6 @@ pub struct Queue {
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
/// Nanoseconds the window's thread waited for the submit thread since the last
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
waited: AtomicU64,
}
impl Queue {
@@ -64,7 +60,6 @@ impl Queue {
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
waited: AtomicU64::new(0),
};
if !threaded {
return this;
@@ -135,24 +130,15 @@ impl Queue {
return;
}
if let Some(l) = landing.as_mut() {
let started = Instant::now();
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
self.waited.fetch_add(nanos, Ordering::Relaxed);
}
self.pending.store(false, Ordering::Release);
}
/// How long the window's thread waited for the submit thread since the last call: in a
/// GPU-bound view, the previous frame's submission and present hold up every use of the
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
pub fn take_waited(&self) -> Duration {
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
@@ -186,7 +172,7 @@ mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
@@ -1,14 +0,0 @@
//! 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())
}
+3 -92
View File
@@ -30,8 +30,6 @@ 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,
@@ -133,14 +131,6 @@ 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 {
@@ -226,7 +216,7 @@ impl GpuTimer {
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FAILED },
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
@@ -243,21 +233,13 @@ impl GpuTimer {
}
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
if device.poll(wgpu::PollType::Poll).is_err() {
return;
}
let _ = device.poll(wgpu::PollType::Poll);
for i in 0..SLOTS {
let slot = &self.slots[(self.next + i) % SLOTS];
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
{
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;
};
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
// 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.
@@ -303,74 +285,3 @@ 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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
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 = crate::gfx::test_gpu::instance();
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter =
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.unwrap();
@@ -10,8 +10,6 @@
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The least a world is scaled to when memory runs out.
const MIN_SCALE: f32 = 0.25;
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
@@ -324,65 +322,12 @@ impl Post {
self.target = None;
return None;
}
// The old target goes first: a slider drag would otherwise hold the old and the new
// targets together, and the sum is what runs a small GPU out of memory.
loop {
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
{
break;
}
self.target = None;
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
// that failed is invalid, so its view fails validation too: catch that as well.
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let target = self.build_target(device, size, history_size);
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
if error.is_none() {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(target);
break;
}
drop(target);
if self.scale <= MIN_SCALE {
log::warn!(
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = 1.0;
self.target = None;
return None;
}
let reduced = (self.scale * 0.75).max(MIN_SCALE);
log::warn!(
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
self.scale,
error.map_or_else(String::new, |e| e.to_string())
);
self.scale = reduced;
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
return None;
}
}
self.target
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
/// is set.
fn build_target(
&self,
device: &wgpu::Device,
size: [u32; 2],
history_size: Option<[u32; 2]>,
) -> WorldTarget {
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -458,14 +403,19 @@ impl Post {
show,
})
});
WorldTarget {
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
size,
color,
depth,
group,
taa,
}
});
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
@@ -618,7 +568,8 @@ mod tests {
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
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()
}
+16 -39
View File
@@ -19,28 +19,20 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -464,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). Where it can't, the settings grey its switch out.
/// arena's translucent quads).
pub fn offer_translucent_batching(on: bool) {
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
@@ -590,9 +582,8 @@ pub struct Graphics {
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -610,11 +601,9 @@ 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. 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.
/// 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`).
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
@@ -657,7 +646,7 @@ impl Default for Graphics {
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: true,
translucent_batching: false,
shader_pack: String::new(),
}
}
@@ -750,7 +739,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),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"translucentBatching" => 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" => {
@@ -767,7 +756,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:{}\nbatchTranslucent:{}\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:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -839,14 +828,6 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -967,7 +948,7 @@ mod tests {
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: false,
translucent_batching: true,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -987,12 +968,8 @@ mod tests {
EntityCullingMode::Off
);
assert_eq!(Graphics::parse(""), Graphics::default());
// 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);
// Translucent Batching is opt-in for now: only the file turns it on.
assert!(!Graphics::default().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, caching, batching).
/// Turning it on makes frames faster (culling).
Saves,
}
impl Cost {
+17 -211
View File
@@ -61,7 +61,6 @@ pub enum H {
BatteryMode,
ShadowCache,
GraphicsApi,
TranslucentBatching,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -358,11 +357,6 @@ 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,
@@ -376,13 +370,11 @@ pub fn rows(level: Level) -> Vec<Row> {
),
],
};
// Only Windows and Linux have more than one graphics API, and translucent batching
// (Metal draws flat).
// Only Windows and Linux have more than one graphics API.
if cfg!(target_os = "macos") {
rows.retain(|row| {
!matches!(row, Row::Setting(s)
if matches!(s.item, Item::Horizon(H::GraphicsApi | H::TranslucentBatching)))
});
rows.retain(
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
);
}
rows
}
@@ -606,21 +598,24 @@ impl H {
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", n.to_string()),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
},
H::HdrBlackLevel => labelled(
"Black Level",
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
@@ -628,12 +623,6 @@ 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 {
@@ -729,7 +718,6 @@ 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,
@@ -760,8 +748,7 @@ 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; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
/// while it is on.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -777,11 +764,6 @@ 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,
}
}
@@ -798,7 +780,6 @@ 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,
@@ -862,7 +843,6 @@ impl Item {
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -1488,12 +1468,6 @@ 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;
@@ -2012,66 +1986,6 @@ 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;
@@ -2226,112 +2140,4 @@ mod tests {
}
}
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the three pages show, with each setting cycled
/// through its values (#1927).
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for _ in 0..8 {
for row in extra_rows(level, &s) {
match row {
Row::Header(name) => {
lang::get(name);
}
Row::Setting(row) => {
row.item.label(&s);
let text = description(row.item, &s, row.text);
lang::get(text);
lang::get(row.cost.label());
if let Some(why) = row.item.blocked(&s) {
lang::get(why);
}
}
}
}
for row in extra_rows(level, &s) {
if let Row::Setting(Setting {
item: Item::Horizon(h),
..
}) = row
{
h.cycle(&mut s, false);
}
}
}
}
}
for button in ["Advanced...", "Simple...", "Done"] {
lang::get(button);
}
});
// `labelled` also looks up values that are already words in the language ("An") or
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
// a name.
let missed: Vec<_> = missed
.into_iter()
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
.collect();
assert_eq!(missed, Vec::<String>::new());
}
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
#[test]
fn newer_graphics_settings_are_translated() {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
let item = |h| Item::Horizon(h);
(
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
"{code}"
);
}
}
}
+21 -36
View File
@@ -20,11 +20,12 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// its fully lifted pixels get.
// the brightest pixels get, and the brightness where the lift starts and
// where it is full.
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.
@@ -39,11 +40,9 @@ 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. 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.
// (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.
// 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.
@@ -55,7 +54,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -158,38 +157,24 @@ 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 hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
// 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, 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.
// (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.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
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 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));
@@ -49,7 +49,7 @@ pub struct Calibration {
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 3],
auto: [Option<f32>; 2],
dragging: bool,
}
@@ -125,12 +125,11 @@ impl Calibration {
Self {
step: 0,
values: [
g.black_level_nits(),
g.hdr_black_level as f32 / 100.0,
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -147,11 +146,15 @@ impl Calibration {
fn label(&self) -> Text {
let v = self.values[self.step];
let step = self.current();
let value = match step {
let nits = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!("{}: {}", step.name(), value))
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -281,12 +284,11 @@ impl Calibration {
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
let scale = [100.0, 1.0, 1.0];
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
Some(auto) if auto == self.values[i] => 0,
_ => (self.values[i] * scale[i]).round() as u32,
_ => self.values[i].round() as u32,
});
g.hdr_black_level = black;
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
+28 -668
View File
@@ -10,7 +10,6 @@ use std::{
};
use wgpu::util::DeviceExt;
pub mod cit_glint;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
@@ -86,10 +85,6 @@ 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.
@@ -142,34 +137,6 @@ 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;
@@ -248,126 +215,6 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
/// first frame; a still texture is packed whole. 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 rules = crate::cit::load_rules(root);
let mut ids = crate::cit_assets::item_sprites(&rules);
if !rules.is_empty() {
let models = crate::cit_assets::load_models(root, &rules);
ids.extend(crate::cit_assets::model_sprites(&models));
ids.extend(crate::cit_armor::armor_sprites(&rules));
}
let ids: Vec<String> = ids
.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] = &[
@@ -643,27 +490,6 @@ 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
@@ -672,21 +498,25 @@ impl HudAtlas {
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
/// A width where the plain shelves (each image after the last) leave too
/// much unused is tried again with each image in the first shelf with
/// room: a pack of thousands of mixed sizes (server packs with custom
/// items) fits where the shelves' ragged ends would not.
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough {
for first_fit in [false, true] {
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
if glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
@@ -699,33 +529,6 @@ impl HudAtlas {
width = (width * 2).min(max);
}
}
/// `layout`'s shelves at one width: the placements and the first row below
/// them. `first_fit` puts an image in the first shelf that is tall enough
/// and has room, instead of only the last one.
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
// Each shelf: its top row, its height (its first image's) and the next free column.
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
let mut placements = Vec::with_capacity(images.len());
for (_, w, h, _) in images {
let open = if first_fit {
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
} else {
shelves
.len()
.checked_sub(1)
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
};
let i = open.unwrap_or_else(|| {
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
shelves.push((top, *h, 0));
shelves.len() - 1
});
placements.push((shelves[i].2, shelves[i].0));
shelves[i].2 += w + 1;
}
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
(placements, glyph_region)
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
@@ -782,66 +585,15 @@ 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();
let extra = random_textures
.files()
.chain(emissive.files())
.chain(blinks.files());
for (file, name) in extra {
for (file, name) in random_textures.files() {
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> = kept
.iter()
let mut extra: Vec<PathBuf> = named
.keys()
.filter(|p| !walked.contains(p))
.cloned()
.collect();
@@ -930,21 +682,10 @@ 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
@@ -979,8 +720,7 @@ 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_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
let (width, placements, glyph_region) = Self::layout(&images, 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);
@@ -1075,8 +815,6 @@ impl HudAtlas {
glint_armor,
text_shader: None,
random_textures,
emissive,
blinks,
font,
white,
})
@@ -1186,9 +924,6 @@ pub enum Blend {
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
/// projected from the screen (`HudBatch::end_portal` sets the textures).
EndPortal,
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
/// `GlintTextures::blends` says.
CitGlint(u8),
}
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
@@ -1633,7 +1368,7 @@ impl HudBatch {
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 1.0,
offset: 0.0,
}
} else {
font.glyph_styled(ch, style)
@@ -1695,6 +1430,12 @@ 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;
@@ -1715,18 +1456,6 @@ 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 {
@@ -1804,9 +1533,10 @@ impl HudBatch {
object: &crate::text::InlineObject,
x: f32,
y: f32,
tint: u32,
alpha: u8,
) {
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;
@@ -1923,11 +1653,7 @@ pub struct HudRenderer {
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// The pack's CIT enchantment glints, when it has any.
cit_glint: Option<cit_glint::CitGlintPass>,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
@@ -2160,9 +1886,7 @@ impl HudRenderer {
depth_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
group,
cit_glint: None,
uniform,
buffer,
capacity,
@@ -2172,18 +1896,6 @@ impl HudRenderer {
zoom: 1.0,
})
}
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
/// textures draws none.
pub fn set_cit_glints(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
@@ -2419,12 +2131,6 @@ impl HudRenderer {
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
}
continue;
}
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
@@ -2614,106 +2320,6 @@ 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 =
@@ -2858,232 +2464,6 @@ 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_cit_models_relative_texture_is_packed() {
let root = cit_root(
"model",
&[
("a.properties", "items=stick\nmodel=sword"),
(
"sword.json",
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 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 an_armor_rules_texture_is_packed_too() {
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);
let rules = crate::cit::load_rules(&root);
assert_eq!(rules.len(), 1);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
// Worn armour draws from the same atlas, under the name the layer looks up.
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
assert!(atlas.sprites.contains_key(&layer.unwrap()));
}
#[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)
@@ -3185,19 +2565,6 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn mixed_sizes_fill_the_ends_of_the_shelves() {
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
// 4000-wide ones (same height) come after them, and on shelves of their
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
// Each in a gap, it is 15.
let mut images = sized(15, 9000, 1000);
images.extend(sized(15, 4000, 1000));
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
assert_eq!(width, 16384);
assert!(free + GLYPH_ROWS <= 16384);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
@@ -3358,13 +2725,6 @@ texture=wide",
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();
}
-29
View File
@@ -41,35 +41,6 @@ struct Out {
if c.a < 0.004 { discard; }
return c;
}
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
struct CitSlot {
rgba: vec4<f32>,
// x: linear filtering (`blur`).
blur: vec4<f32>,
}
struct CitSlots {
slots: array<CitSlot, 256>,
}
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
@group(1) @binding(1) var cit_blur: sampler;
@group(1) @binding(2) var cit_nearest: sampler;
@group(1) @binding(3) var<uniform> cit: CitSlots;
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
let index = u32(round(in.color.r * 255.0));
let slot = cit.slots[index];
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
if mask < 0.1 { discard; }
// The layer's colour times its intensity (the glint strength option included) in alpha,
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
t.a *= in.color.a;
if t.a < 0.1 { discard; }
return t;
}
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
if c.a == 0.0 { discard; }
@@ -1,563 +0,0 @@
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
pub const MAX_SLOTS: usize = 256;
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
const MAX_SIDE: u32 = 1024;
/// The most the texture array takes, in bytes: past it the shared size is halved.
const MAX_BYTES: u64 = 64 << 20;
/// One slot's constants.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
/// The rule's `r`, `g`, `b`, `a`.
pub rgba: [f32; 4],
/// Linear filtering (`blur`), else nearest.
pub blur: bool,
/// An index into `GlintTextures::blends`.
pub blend: u8,
}
/// The enchantment rules' textures, resized to one size.
#[derive(Debug, Default)]
pub struct GlintTextures {
pub width: u32,
pub height: u32,
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
/// split-screen player's renderer builds its own array from it.
pub pixels: Vec<u8>,
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
/// textures that did not load and rules past `MAX_SLOTS`.
pub slot_of: Vec<Option<u8>>,
pub slots: Vec<Slot>,
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
pub blends: Vec<Option<Blend>>,
}
impl GlintTextures {
/// The textures of `rules` under the overlay resource root.
pub fn load(root: &Path, rules: &[Rule]) -> Self {
let mut images = Vec::new();
let mut past = 0;
for (i, rule) in rules.iter().enumerate() {
let Some(path) = rule.texture.as_deref() else {
continue;
};
if rule.kind != Kind::Enchantment {
continue;
}
if images.len() == MAX_SLOTS {
past += 1;
continue;
}
match super::decode(&root.join(path)) {
Ok(image) => images.push((i, image)),
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
}
}
if past > 0 {
log::warn!(
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
);
}
Self::new(rules, images, MAX_BYTES)
}
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
return Self::default();
};
let mut out = Self {
width,
height,
slot_of: vec![None; rules.len()],
..Self::default()
};
for (rule, (w, h, rgba)) in images {
let glint = &rules[rule].glint;
out.pixels
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
Some(i) => i,
None => {
out.blends.push(glint.blend);
out.blends.len() - 1
}
};
out.slot_of[rule] = Some(out.slots.len() as u8);
out.slots.push(Slot {
rgba: glint.rgba,
blur: glint.blur,
blend: blend as u8,
});
}
out
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// The size every slot takes: the largest width and height of `sizes`, each at most
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
fn shared_size(
sizes: impl Iterator<Item = (u32, u32)>,
layers: usize,
budget: u64,
) -> Option<(u32, u32)> {
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
})?;
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
while bytes(w, h) > budget && (w > 1 || h > 1) {
(w, h) = ((w / 2).max(1), (h / 2).max(1));
}
Some((w, h))
}
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
/// the glint repeats over the item.
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
if from == to {
return rgba.to_vec();
}
let ((w, h), (to_w, to_h)) = (from, to);
let texel = |x: i64, y: i64| {
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
let at = (y as usize * w as usize + x as usize) * 4;
&rgba[at..at + 4]
};
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
for y in 0..to_h {
let sy = centre(y, to_h, h);
for x in 0..to_w {
let sx = centre(x, to_w, w);
if !smooth {
let pick = |i: u32, n: u32, size: u32| {
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
};
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
continue;
}
let (fx, fy) = (sx - 0.5, sy - 0.5);
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
let [a, b, c, d] = [
texel(x0, y0),
texel(x0 + 1, y0),
texel(x0, y0 + 1),
texel(x0 + 1, y0 + 1),
];
let lerp =
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
out.extend((0..4).map(|k| {
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
(top * (1.0 - ty) + bottom * ty).round() as u8
}));
}
}
out
}
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
/// degrees, as OptiFine's doc has it.
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
let clock = f64::from(millis) * f64::from(speed);
let x = (clock % 110_000.0 / 110_000.0) as f32;
let y = (clock % 30_000.0 / 30_000.0) as f32;
let (sin, cos) = rotation.to_radians().sin_cos();
let (u, v) = (model[0] * scale, model[1] * scale);
[u * cos - v * sin - x, u * sin + v * cos + y]
}
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
super::rgba(slot, 0, 0, alpha)
}
fn factor(f: Factor) -> wgpu::BlendFactor {
use wgpu::BlendFactor as W;
match f {
Factor::Zero => W::Zero,
Factor::One => W::One,
Factor::SrcColor => W::Src,
Factor::OneMinusSrcColor => W::OneMinusSrc,
Factor::SrcAlpha => W::SrcAlpha,
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
Factor::DstAlpha => W::DstAlpha,
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
Factor::DstColor => W::Dst,
Factor::OneMinusDstColor => W::OneMinusDst,
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
}
}
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
let Some(b) = blend else {
return wgpu::BlendState::REPLACE;
};
let component = |src, dst| wgpu::BlendComponent {
src_factor: factor(src),
dst_factor: factor(dst),
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component(b.src, b.dst),
alpha: component(b.src_alpha, b.dst_alpha),
}
}
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
}
impl CitGlintPass {
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
return None;
}
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
// layers kept sample the last one.
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
let max = device.limits().max_texture_array_layers.max(1);
let slots = textures.slots.len() as u32;
let mut kept = slots.min(max);
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
kept -= 1;
}
if kept < slots {
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
}
let layers = crate::gfx::array_layers(kept, gl).min(max);
let layer_bytes = textures.width as usize * textures.height as usize * 4;
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
if layers != slots {
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
padded.resize(layer_bytes * layers as usize, 0);
data = std::borrow::Cow::Owned(padded);
}
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("CIT glints"),
size: wgpu::Extent3d {
width: textures.width,
height: textures.height,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
// hud.wgsl's `CitSlot`: rgba, then blur in x.
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
for (row, slot) in table.iter_mut().zip(&textures.slots) {
row[..4].copy_from_slice(&slot.rgba);
row[4] = if slot.blur { 1.0 } else { 0.0 };
}
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("CIT glint slots"),
contents: bytemuck::cast_slice(&table),
usage: wgpu::BufferUsages::UNIFORM,
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty,
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("CIT glint bindings"),
entries: &[
entry(
0,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
),
entry(1, filtering),
entry(2, filtering),
entry(
3,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
),
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("CIT glint resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&linear),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
wgpu::BindGroupEntry {
binding: 3,
resource: table.as_entire_binding(),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD (CIT glints)"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("CIT glint layout"),
bind_group_layouts: &[Some(hud), Some(&layout)],
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let pipelines = textures
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cit_glint"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(blend_state(blend)),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
Some(Self { group, pipelines })
}
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
use horizon_optifine::cit::parse_blend;
fn rule(name: &str, text: &str) -> Rule {
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
let text = format!("texture=glint\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
#[test]
fn slots_share_one_size_and_their_blends() {
let rules = [
rule("a", "type=enchantment\nr=0.5"),
rule("item", "items=stick"),
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
rule("c", "type=enchantment"),
];
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
let red = vec![255, 0, 0, 255];
let quad: Vec<u8> = (0..16).collect();
let t = GlintTextures::new(
&rules,
vec![
(0, (1, 1, red)),
(2, (2, 2, quad.clone())),
(3, (2, 2, quad.clone())),
],
MAX_BYTES,
);
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
assert_eq!(&t.pixels[16..32], &quad[..]);
assert_eq!(
t.blends,
[
parse_blend("add").unwrap(),
parse_blend("multiply").unwrap()
]
);
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
assert_eq!(blends, [0, 1, 0]);
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
assert!(!t.slots[1].blur && t.slots[2].blur);
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
}
#[test]
fn big_glints_are_scaled_down_to_the_largest_side() {
let rules = [rule("wide", "type=enchantment\nblur=false")];
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
let wide: Vec<u8> = (0..2048u32)
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
.collect();
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
assert_eq!((t.width, t.height), (1024, 1));
for x in [0u32, 1, 300, 1023] {
let at = x as usize * 4;
let source = 2 * x;
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
}
}
#[test]
fn the_array_is_halved_until_it_fits() {
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
// The largest of each side, never past MAX_SIDE.
let mixed = [(16, 4096), (64, 32)].into_iter();
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
}
#[test]
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
let row = [0, 0, 0, 255, 255, 255, 255, 255];
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
}
#[test]
fn a_layer_vertex_carries_its_slot_and_intensity() {
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
}
#[test]
fn a_still_layer_is_scaled_and_turned() {
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
// No speed: no scroll whatever the clock.
assert!(close(
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
[2.0, 4.0]
));
// A quarter turn takes (1, 0) to (0, 1).
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
assert!(close(
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
[-3.0 / 110.0, 0.1]
));
}
#[test]
fn named_blends_take_their_gl_factors() {
let multiply = blend_state(parse_blend("multiply").unwrap());
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
assert_eq!(
multiply.color.dst_factor,
wgpu::BlendFactor::OneMinusSrcAlpha
);
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
assert_eq!(
blend_state(parse_blend("replace").unwrap()),
wgpu::BlendState::REPLACE
);
}
}
@@ -1,154 +0,0 @@
//! A data folder's files read from the disk once while a load holds them.
//! Several tables are built from the same folder (the recipes: the crafting
//! tables, the stations, the recipe book, brewing), and each used to list and
//! read every file again: on a hard disk or under a virus scanner, each open
//! costs. The first reader lists and reads the folder; later readers share
//! those bytes for as long as one of them holds the [`Folder`], then it is
//! gone (nothing is kept past the load).
use std::{
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
};
/// One file: its path inside the folder without `.json` (`a/b`, as an id has
/// it), the file, and its bytes or the error reading it gave.
pub struct File {
pub name: String,
pub path: PathBuf,
bytes: Result<Box<[u8]>, (io::ErrorKind, String)>,
}
impl File {
pub fn bytes(&self) -> io::Result<&[u8]> {
self.bytes
.as_deref()
.map_err(|(kind, message)| io::Error::new(*kind, message.clone()))
}
}
/// Every `.json` file under a folder, its subfolders' too, in name order.
pub struct Folder {
pub files: Vec<File>,
}
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
open.iter()
.find(|(d, _)| d == dir)
.and_then(|(_, f)| f.upgrade())
}
/// `dir`'s files if another reader holds them, without reading anything.
pub fn held(dir: &Path) -> Option<Arc<Folder>> {
find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir)
}
/// The `.json` files under `dir`: shared with any other reader holding them,
/// else listed and read now. An error when `dir` itself can't be listed.
pub fn read(dir: &Path) -> io::Result<Arc<Folder>> {
if let Some(folder) = held(dir) {
return Ok(folder);
}
let files = list(dir)?
.into_iter()
.map(|(name, path)| {
let bytes = fs::read(&path)
.map(Vec::into_boxed_slice)
.map_err(|e| (e.kind(), e.to_string()));
File { name, path, bytes }
})
.collect();
let folder = Arc::new(Folder { files });
let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner());
// Another reader may have read it meanwhile: share one copy.
if let Some(theirs) = find(&open, dir) {
return Ok(theirs);
}
open.retain(|(_, f)| f.strong_count() > 0);
open.push((dir.to_owned(), Arc::downgrade(&folder)));
Ok(folder)
}
/// The `.json` files under `dir` and its subfolders, in name order: each
/// one's path inside the folder without the extension (`a/b`) and its file.
/// An error when `dir` itself can't be listed; a subfolder or an entry that
/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps
/// what it found.
pub fn list(dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("brewing")).unwrap();
fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap();
fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap();
fs::write(dir.join("notes.txt"), b"not json").unwrap();
dir
}
#[test]
fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() {
let dir = folder("share");
let first = read(&dir).unwrap();
let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["brewing/awkward", "stick"]);
assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}");
// Held: a change on the disk isn't seen, the copy read is shared.
fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap();
let second = read(&dir).unwrap();
assert!(Arc::ptr_eq(&first, &second));
drop((first, second));
// Let go: the next reader reads the folder again.
assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_that_cannot_be_listed_is_an_error() {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id()));
assert!(read(&dir).is_err());
}
}
-5
View File
@@ -11,7 +11,6 @@ pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod border_render;
pub mod breaking;
@@ -21,9 +20,6 @@ pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -49,7 +45,6 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
+16 -202
View File
@@ -26,11 +26,6 @@ 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.
@@ -903,9 +898,8 @@ struct Shared {
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
}
/// 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.
/// 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.
pub struct Lod {
shared: Arc<Shared>,
thread: Option<thread::JoinHandle<()>>,
@@ -1199,17 +1193,12 @@ impl Lod {
if worker.stop.load(Ordering::Relaxed) {
break;
}
// 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() {
if sampling.is_empty() {
jobs.pop_front()
} else {
None
jobs.iter()
.position(|job| !matches!(job, Job::Sample(_)))
.and_then(|index| jobs.remove(index))
}
};
let Some(job) = job else { continue };
@@ -1271,7 +1260,7 @@ impl Lod {
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
small: cores <= 4,
sample_jobs: sampling_threads(cores) * QUEUED_PER_SAMPLER,
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
@@ -1337,31 +1326,14 @@ 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))
.map(detail_count)
.flat_map(|mask| mask.iter())
.map(|bits| bits.count_ones() as usize)
.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)
}
@@ -1639,11 +1611,6 @@ 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
@@ -1766,43 +1733,9 @@ 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: far terrain then queues
/// `SAMPLE_JOBS` at most, and on small machines holds back
/// (`HELD_SAMPLE_STEP`).
/// Whether the near terrain is still loading: on small machines far
/// terrain then 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 {
@@ -1831,7 +1764,10 @@ impl Lod {
.active
.iter()
.filter(|key| {
self.wants_detail(key) && self.detail_masks.get(key) != Some(&[u64::MAX; 4])
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])
})
.copied()
.collect();
@@ -1940,11 +1876,6 @@ 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.
@@ -2314,8 +2245,7 @@ impl Lod {
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
)
} else {
// `SAMPLE_JOBS` while the near terrain loads, the full queue
// (`QUEUED_PER_SAMPLER` a thread) once it is in.
// The extra sampling threads wait until the near terrain is in.
let slots = if self.near_loading {
SAMPLE_JOBS
} else {
@@ -2537,28 +2467,6 @@ 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.
@@ -3687,100 +3595,6 @@ 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,11 +160,6 @@ 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)]
@@ -243,90 +238,6 @@ 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();
+83 -566
View File
@@ -7,26 +7,23 @@ mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, Light, MIN_Y},
chunk::{Chunk, MIN_Y},
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::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
registry::BlockStateId,
};
pub use sides::{Part, Sides, group_parts};
pub use sides::Sides;
use std::sync::Arc;
/// An entity, item and effect vertex (the entity pipelines), with a face
@@ -283,7 +280,7 @@ impl SectionMesh {
pub const BOUNDS_PAD: f32 = 0.5;
/// Retain geometry capacity after an upload, but none of its old contents.
pub fn clear(&mut self) {
pub(crate) fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
@@ -475,9 +472,6 @@ 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,
@@ -505,10 +499,6 @@ pub struct MeshFacts {
}
pub struct MeshTables {
info: Vec<Info>,
/// `Info::solid` by state, packed apart: the opacity mask of a section
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -662,7 +652,6 @@ 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,
@@ -677,7 +666,6 @@ impl MeshTables {
});
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -847,66 +835,43 @@ impl Padded {
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read one by one,
// each run of them from a section found once.
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
}
// The x runs of a padded row by the chunk column they fall in.
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
for y in -B..16 + B {
let wy = base_y + y;
for z in -B..16 + B {
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
for (rx, x0, x1) in RUNS {
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
let Some(c) = region[rz][rx] else {
match region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
Some(c) => {
let ez = z.clamp(0, 15);
for (k, x) in (x0..x1).enumerate() {
let ex = x.clamp(0, 15);
blocks[k] = c.get(ex, wy, ez);
light[k] = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
}
continue;
};
// `Chunk::get` and `Chunk::light` with the section found once.
let s = (wy - c.min_y()) >> 4;
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
blocks.fill(0);
light.fill(if s < 0 { 0 } else { 0xF0 });
continue;
};
let at = crate::chunk::index(x0, wy, z);
if rx == 1 && rz == 1 && (0..16).contains(&y) {
let own_row = (y << 8 | z << 4) as usize;
blocks.copy_from_slice(&own[own_row..own_row + 16]);
for x in -B..16 + B {
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
let i = pi(x, y, z);
if let Some(c) = chunk {
blocks[i] =
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
{
own[(y << 8 | z << 4 | x) as usize]
} else {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
section.blocks.get_run(at, blocks);
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
light[i] = 0xF0;
}
}
}
@@ -917,33 +882,11 @@ impl Padded {
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
#[derive(Default)]
pub struct MeshScratch {
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
}
#[cfg(test)]
mod leaf_culling_tests;
@@ -1040,12 +983,6 @@ enum Resolver {
/// layer plus the blend radius around it, and the blended colours per column.
struct Layer {
zoomed: Vec<u16>,
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
/// a cell is in the blend square of up to `(2r+1)^2` columns.
cells: [Vec<u32>; 4],
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
/// their offset from the first: each is shared by up to 16 cells of the layer.
jitter: Vec<Option<[[f64; 3]; 8]>>,
colors: [[u32; 256]; 4],
}
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
@@ -1080,42 +1017,6 @@ 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));
Self::quart_biome(region, plains, [cx, cz], q)
}
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
/// there is none.
fn quart_biome(
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz]: [i32; 2],
q: [i32; 3],
) -> u16 {
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;
@@ -1123,8 +1024,6 @@ impl<'a> Tints<'a> {
let layer = self.layers[y as usize].get_or_insert_with(|| {
Box::new(Layer {
zoomed: vec![NO_BIOME; dim * dim],
cells: Default::default(),
jitter: Vec::new(),
colors: [[UNSET; 256]; 4],
})
});
@@ -1136,42 +1035,24 @@ impl<'a> Tints<'a> {
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
let cells = &mut layer.cells[resolver as usize];
if cells.is_empty() {
cells.resize(dim * dim, UNSET);
}
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let known = cells[cell];
if known != UNSET {
red += known >> 16 & 255;
green += known >> 8 & 255;
blue += known & 255;
continue;
}
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none): `quart_at_block`, its cell's
// corner jitter mixed once per layer.
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
let span = dim / 4 + 2;
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
if layer.jitter.is_empty() {
layer.jitter.resize(span * span, None);
}
let jitter =
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
let corner = BiomeZoom::select_corner(block, jitter);
let q = [
base[0].wrapping_add((corner >> 2 & 1) as i32),
base[1].wrapping_add((corner >> 1 & 1) as i32),
base[2].wrapping_add((corner & 1) as i32),
];
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
// 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
};
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1183,7 +1064,6 @@ impl<'a> Tints<'a> {
Resolver::Dry => self.biomes.get(biome).dry_foliage,
Resolver::Water => self.biomes.get(biome).water,
};
cells[cell] = c;
red += c >> 16 & 255;
green += c >> 8 & 255;
blue += c & 255;
@@ -1237,9 +1117,7 @@ impl Mesher<'_> {
&mut MeshScratch::default(),
)
}
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
/// rather than allocated per section.
pub fn mesh_reusing(
pub(crate) fn mesh_reusing(
&mut self,
region: &[[Option<&Chunk>; 3]; 3],
sy: i32,
@@ -1256,8 +1134,6 @@ impl Mesher<'_> {
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1290,7 +1166,12 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if hidden(opaque, x, y, z) {
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
})
{
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1348,34 +1229,11 @@ 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,
@@ -1384,8 +1242,7 @@ impl Mesher<'_> {
origin,
face,
ao,
(id, state),
world,
state,
interior_leaf,
);
}
@@ -1424,8 +1281,7 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
(id, state): (u32, &Info),
world: BlockPos,
state: &Info,
interior_leaf: bool,
) {
let q = &face.quad;
@@ -1453,389 +1309,50 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// 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])),
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|| {
if state.pane {
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
} else {
CtmAxes::texture(&q.positions, &q.uv)
}
},
|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));
}
}
}
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
// 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 translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
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;
// 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)),
));
}
}
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,7 +322,6 @@ mod tests {
legacy_solid: false,
overlay: leaves,
leaves,
pane: false,
opaque_cube: opaque,
blocks_flow: false,
ice: false,
+57 -365
View File
@@ -16,15 +16,6 @@
//! 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
@@ -53,37 +44,12 @@ enum Bucket {
Other,
}
/// 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.
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
/// whether it can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Sides {
/// 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,
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
ends: [u32; 7],
/// Per bucket facing a positive direction, its lowest plane; facing a
/// negative one, its highest (unused for the other bucket).
planes: [f32; 7],
@@ -93,98 +59,43 @@ 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; 14];
ends[OTHER * 2..].fill(quads);
let mut ends = [0; 7];
ends[OTHER..].fill(quads);
Self {
ends,
grouped: false,
split: false,
planes: [0.0; 7],
}
}
/// A sort with these parts' `ends` and `planes`, for tests.
#[cfg(test)]
pub(crate) fn from_parts(
ends: [u32; 14],
grouped: bool,
split: bool,
planes: [f32; 7],
) -> Self {
Self {
ends,
grouped,
split,
planes,
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[13]
self.ends[6]
}
/// 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], Part::All, draw);
self.visible_from(eye, eye[1], 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. 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);
}
/// How many quads `part` draws facing every way (`every`).
pub fn part_quads(&self, part: Part) -> u32 {
let mut quads = 0;
self.every(part, |_, count| quads += count);
quads
}
/// The facts `visible_from` reads, for the GPU to find the same runs
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
/// then 1 when grouped plus 2 when split.
pub fn gpu_row(&self) -> [u32; 22] {
let mut row = [0; 22];
row[..14].copy_from_slice(&self.ends);
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
*to = plane.to_bits();
}
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
row
}
/// 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)) {
/// 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)) {
let mut start = 0;
let mut run: Option<u32> = None;
for (n, &end) in self.ends.iter().enumerate() {
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = self.ends[i];
if end == start {
continue;
}
let (bucket, cutout) = slot(self.grouped, n);
let wanted = match part {
Part::All => true,
_ if !self.split => true,
Part::Opaque => !cutout,
Part::Cutout => cutout,
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,
};
match (wanted && seen(bucket), run) {
match (seen, run) {
(true, None) => run = Some(start),
(false, Some(first)) => {
draw(first, start - first);
@@ -200,15 +111,6 @@ 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 {
@@ -237,56 +139,18 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// 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.
/// 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.
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 {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
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 = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
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 mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
for (quad, &b) in quads.iter().zip(&buckets) {
let b = usize::from(b);
if let Bucket::Side(axis, positive) = BUCKETS[b] {
let p = quad[0].pos[axis];
planes[b] = match (counts[b], positive) {
@@ -297,122 +161,48 @@ fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortSc
}
counts[b] += 1;
}
let mut slot_ends = [0u32; 14];
let mut ends = [0u32; 7];
let mut total = 0;
for (end, count) in slot_ends.iter_mut().zip(slot_counts) {
for (end, count) in ends.iter_mut().zip(counts) {
total += count;
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
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;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
grouped,
split: true,
planes,
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
}
#[cfg(test)]
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -435,12 +225,6 @@ 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)));
@@ -477,7 +261,7 @@ mod tests {
v.layer = (i / 4) as u16;
}
let sides = sort(&mut solid);
assert_eq!(bucket_ends(&sides), [1, 2, 2, 3, 4, 5, 6]);
assert_eq!(sides.ends, [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]);
@@ -564,7 +348,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, Part::All, |first, count| out.push((first, count)));
sides.visible_from(eye, low, |first, count| out.push((first, count)));
out
};
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
@@ -576,98 +360,6 @@ 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);
@@ -693,7 +385,7 @@ mod tests {
.collect();
let before = solid.clone();
let sides = sort(&mut solid);
assert_eq!(bucket_ends(&sides), [3; 7]);
assert_eq!(sides.ends, [3; 7]);
assert_eq!(
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
before.iter().map(|v| v.layer).collect::<Vec<_>>()
@@ -88,7 +88,6 @@ fn info(solid: bool) -> Info {
legacy_solid: false,
overlay: false,
leaves: false,
pane: false,
opaque_cube: solid,
blocks_flow: false,
ice: false,
Loaded 100 of 707 files, more files were not shown because too many files have changed in this diff. Show more