mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51400dae12 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: mojang republish
|
||||
# Hourly: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
|
||||
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
|
||||
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
|
||||
# gh workflow run "mojang republish" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 * * * *' # hourly: a republished JSON breaks horizon-prepare for every new player until accepted
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: mojang-republish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -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"
|
||||
@@ -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`...) |
|
||||
@@ -58,7 +56,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -86,8 +83,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 +101,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 +127,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 +150,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 +159,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
-77
@@ -1314,7 +1314,6 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1324,7 +1323,6 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1336,9 +1334,6 @@ dependencies = [
|
||||
"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",
|
||||
@@ -1375,62 +1370,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"
|
||||
@@ -1452,7 +1391,6 @@ dependencies = [
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1537,7 +1475,6 @@ dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1569,15 +1506,6 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1607,7 +1535,6 @@ dependencies = [
|
||||
name = "horizon-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
@@ -1682,7 +1609,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -5120,9 +5046,6 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-theme",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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",
|
||||
@@ -31,7 +30,6 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"xtask",
|
||||
|
||||
@@ -66,52 +66,3 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> 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.
|
||||
|
||||
## 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"
|
||||
}
|
||||
@@ -6,5 +6,5 @@ authors = []
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks, items, mob effects and entity types to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -153,8 +153,8 @@ checks! {
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
|
||||
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
|
||||
/// fall damage follows; from `setback` on the player is also sent back, and a player that keeps
|
||||
/// claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
|
||||
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
|
||||
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
|
||||
/// ones who were, at 113 to 3749.
|
||||
@@ -281,13 +281,6 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -39,11 +39,6 @@ pub enum Change {
|
||||
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
|
||||
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
|
||||
Attributes { sprint: Option<bool> },
|
||||
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
|
||||
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
|
||||
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
|
||||
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
|
||||
Sprinting(bool),
|
||||
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
|
||||
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
|
||||
/// are the server's: they reach the client as attributes.)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,12 +227,12 @@ 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")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -279,16 +281,4 @@ mod tests {
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_cpu_brand() {
|
||||
let cpu = Hardware::detect().cpu.expect("a CPU name");
|
||||
assert!(
|
||||
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
|
||||
"family and model, not a name: {cpu:?}"
|
||||
);
|
||||
assert_eq!(cpu, cpu.trim());
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -156,11 +153,9 @@ struct Options {
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -195,7 +190,6 @@ fn options() -> Result<Options, String> {
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -253,7 +247,6 @@ fn options() -> Result<Options, String> {
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -408,16 +401,6 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -1001,7 +984,7 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
@@ -1056,7 +1039,6 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
@@ -1220,13 +1202,8 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1469,20 +1446,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -82,19 +72,6 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -264,50 +241,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 +254,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())
|
||||
@@ -387,20 +316,6 @@ mod tests {
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -408,194 +323,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"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -213,48 +213,6 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Only what [`Self::fill_from_export`] takes from an export, to keep
|
||||
/// in place of the export itself (a third of its memory).
|
||||
pub fn from_export(r: &serde_json::Value) -> Self {
|
||||
let mut out = Self::default();
|
||||
out.fill_from_export(r);
|
||||
out
|
||||
}
|
||||
/// [`Self::fill_from_export`] from an export already read with
|
||||
/// [`Self::from_export`]: each field the game left empty, each registry
|
||||
/// it did not list.
|
||||
pub fn fill_from_exported(&mut self, e: &Self) {
|
||||
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
|
||||
if field.is_empty() {
|
||||
field.clone_from(from);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, &e.effects);
|
||||
fill(&mut self.enchantments, &e.enchantments);
|
||||
fill(&mut self.attributes, &e.attributes);
|
||||
fill(&mut self.damage_types, &e.damage_types);
|
||||
fill(&mut self.templates, &e.templates);
|
||||
fill(&mut self.persistent_components, &e.persistent_components);
|
||||
fill(&mut self.learnable_recipes, &e.learnable_recipes);
|
||||
for (name, ids) in &e.by_registry {
|
||||
if !self.by_registry.contains_key(name) {
|
||||
self.by_registry.insert(name.clone(), ids.clone());
|
||||
}
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities.clone_from(&e.block_entities);
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
self.block_tag_members.clone_from(&e.block_tag_members);
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
self.clock_time_markers.clone_from(&e.clock_time_markers);
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
self.advancement_criteria
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
|
||||
@@ -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);
|
||||
@@ -85,19 +81,6 @@ impl InteractionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
|
||||
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
|
||||
/// `key.use` reads up and the same call releases an active use
|
||||
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
|
||||
/// Only the screens opened from the keybinds do this (the inventory, chat and
|
||||
/// advancements); the pause menu, server containers, mount inventories and
|
||||
/// dialogs release on the first tick after closing instead, as vanilla does.
|
||||
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
|
||||
if world.using_item() {
|
||||
world.release_using();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -549,7 +549,6 @@ pub fn fullscreen_request(
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
|
||||
@@ -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"
|
||||
@@ -17,9 +17,6 @@ horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
# The narrator option's status and whether the narrator is there (Options.narrator).
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
@@ -73,4 +70,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,18 +93,9 @@ 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>,
|
||||
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
|
||||
/// empty when they ship none.
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -244,39 +235,8 @@ 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.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
} 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);
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
@@ -297,13 +257,8 @@ 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,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1179,15 +1179,6 @@ impl Audio {
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
pub fn set_listener_reserve(&mut self, on: bool) {
|
||||
self.engine.listener_reserve = if on {
|
||||
crate::sound_engine::HEARD_RESERVE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
|
||||
pub fn pause(&mut self) {
|
||||
self.engine.pause_all_except(
|
||||
@@ -1235,17 +1226,6 @@ impl Audio {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
self.play_after(
|
||||
event,
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
|
||||
);
|
||||
}
|
||||
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
|
||||
/// second) from now: `SoundEngine.playDelayed`.
|
||||
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
@@ -1259,7 +1239,8 @@ impl Audio {
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
|
||||
@@ -172,7 +172,6 @@ fn color(v: &Value) -> Option<u32> {
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
|
||||
@@ -388,52 +387,6 @@ impl Biomes {
|
||||
}
|
||||
}
|
||||
|
||||
/// A biome's colours as a mod may change them (`native_biome_colors`), each
|
||||
/// 0xRRGGBB: what tints grass (before the dark forest's modifier; the swamp's
|
||||
/// noise colours replace it), leaves and vines, dry foliage (leaf litter) and
|
||||
/// water.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct BiomeColors {
|
||||
pub grass: u32,
|
||||
pub foliage: u32,
|
||||
pub dry_foliage: u32,
|
||||
pub water: u32,
|
||||
}
|
||||
|
||||
impl Biomes {
|
||||
/// This table with each biome's colours passed through `f`, given the
|
||||
/// biome's name (`minecraft:plains`) and its colours; None when `f`
|
||||
/// changed none. Bits past 0xFFFFFF are dropped.
|
||||
pub fn with_colors(&self, mut f: impl FnMut(&str, &mut BiomeColors)) -> Option<Self> {
|
||||
let mut out = self.clone();
|
||||
let mut changed = false;
|
||||
for b in &mut out.list {
|
||||
let before = BiomeColors {
|
||||
grass: b.grass,
|
||||
foliage: b.foliage,
|
||||
dry_foliage: b.dry_foliage,
|
||||
water: b.water,
|
||||
};
|
||||
let mut colors = before;
|
||||
f(&b.name, &mut colors);
|
||||
let after = BiomeColors {
|
||||
grass: colors.grass & 0xFF_FFFF,
|
||||
foliage: colors.foliage & 0xFF_FFFF,
|
||||
dry_foliage: colors.dry_foliage & 0xFF_FFFF,
|
||||
water: colors.water & 0xFF_FFFF,
|
||||
};
|
||||
if after != before {
|
||||
changed = true;
|
||||
b.grass = after.grass;
|
||||
b.foliage = after.foliage;
|
||||
b.dry_foliage = after.dry_foliage;
|
||||
b.water = after.water;
|
||||
}
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 24-bit colour as sRGB components in 0..1.
|
||||
pub fn rgb(c: u32) -> [f32; 3] {
|
||||
[16, 8, 0].map(|shift| (c >> shift & 255) as f32 / 255.0)
|
||||
@@ -581,81 +534,6 @@ mod pack_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn biomes() -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let effects = |grass: u32, modifier: &str| {
|
||||
json!({"effects": {"grass_color": grass, "foliage_color": 0x112233,
|
||||
"dry_foliage_color": 0x445566, "water_color": 0x3F76E4,
|
||||
"grass_color_modifier": modifier}})
|
||||
};
|
||||
let list = vec![
|
||||
BiomeInfo::from_json("minecraft:plains".into(), &effects(0x91BD59, "none"), &maps),
|
||||
BiomeInfo::from_json(
|
||||
"minecraft:dark_forest".into(),
|
||||
&effects(0x91BD59, "dark_forest"),
|
||||
&maps,
|
||||
),
|
||||
];
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_reaches_the_tints_the_mesher_reads() {
|
||||
let autumn = biomes()
|
||||
.with_colors(|name, c| {
|
||||
c.foliage = 0xD2691E;
|
||||
if name == "minecraft:plains" {
|
||||
c.grass = 0xBFA055;
|
||||
}
|
||||
})
|
||||
.expect("changed");
|
||||
let plains = autumn.id("minecraft:plains");
|
||||
assert_eq!(autumn.grass(plains, 0.0, 0.0), 0xBFA055);
|
||||
assert_eq!(autumn.get(plains).foliage, 0xD2691E);
|
||||
assert_eq!(autumn.get(plains).dry_foliage, 0x445566);
|
||||
assert_eq!(autumn.get(plains).water, 0x3F76E4);
|
||||
// The dark forest's modifier still darkens its (unchanged) grass:
|
||||
// ((0x91BD59 & 0xFEFEFE) + 0x28340A) >> 1.
|
||||
let dark = autumn.id("minecraft:dark_forest");
|
||||
assert_eq!(autumn.grass(dark, 0.0, 0.0), 0x5C7831);
|
||||
assert_eq!(autumn.get(dark).foliage, 0xD2691E);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_change_gives_none_and_alpha_bits_are_dropped() {
|
||||
assert!(biomes().with_colors(|_, _| {}).is_none());
|
||||
assert!(
|
||||
biomes()
|
||||
.with_colors(|_, c| c.water |= 0xFF00_0000)
|
||||
.is_none()
|
||||
);
|
||||
let blue = biomes()
|
||||
.with_colors(|_, c| c.water = 0xFF00_00FF)
|
||||
.expect("changed");
|
||||
assert_eq!(blue.get(0).water, 0x0000FF);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
//! Bulk block edits: runs of blocks up columns written straight into the
|
||||
//! resident sections, a section unpacked and packed once however many of its
|
||||
//! blocks change, instead of a `World::set` per block. Nothing round them is
|
||||
//! told (as `fill ... strict`); the light of each chunk changed is worked out
|
||||
//! again whole, off the game thread, rather than block by block, and each
|
||||
//! section changed is remeshed once.
|
||||
use crate::chunk::index;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::world::World;
|
||||
|
||||
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
|
||||
/// `state` where filter `only` (if any) passes the block there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Run {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y0: i32,
|
||||
pub y1: i32,
|
||||
pub state: u32,
|
||||
pub only: Option<u16>,
|
||||
}
|
||||
|
||||
/// What an edit changed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outcome {
|
||||
/// Blocks changed.
|
||||
pub changed: u64,
|
||||
/// Chunks with a change.
|
||||
pub chunks: usize,
|
||||
/// Changes of the states `tracked` asked for (block entities, watched
|
||||
/// blocks): position, previous and new state.
|
||||
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
|
||||
}
|
||||
|
||||
/// Per state answers, worked out once each.
|
||||
struct Memo(Vec<u8>);
|
||||
impl Memo {
|
||||
#[inline]
|
||||
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
|
||||
let i = state as usize;
|
||||
if i >= self.0.len() {
|
||||
self.0.resize(i + 1, 0);
|
||||
}
|
||||
if self.0[i] == 0 {
|
||||
self.0[i] = 1 + u8::from(ask(state));
|
||||
}
|
||||
self.0[i] == 2
|
||||
}
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Make `runs` in order, each seeing the changes of those before it.
|
||||
/// `matches(filter, state)` says whether a filter passes a state, and
|
||||
/// `tracked(state)` whether its changes are reported; each is asked once
|
||||
/// per state. Runs in unloaded chunks and the parts of runs outside each
|
||||
/// chunk's own column (`Chunk::layout`) are left out.
|
||||
///
|
||||
/// Unlike `World::set`, this records nothing per block: no
|
||||
/// `block_entities.state_changed` (a caller adds or removes block
|
||||
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
|
||||
/// for those states through `tracked`), no `block_log` entry (the chunk
|
||||
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
|
||||
/// paths are not recomputed for these blocks) and no `light_edits` (the
|
||||
/// chunk and its neighbours are lit again whole, through `dirty_light`).
|
||||
pub fn edit_runs(
|
||||
&mut self,
|
||||
runs: &[Run],
|
||||
mut matches: impl FnMut(u16, u32) -> bool,
|
||||
mut tracked: impl FnMut(u32) -> bool,
|
||||
) -> Outcome {
|
||||
let mut out = Outcome::default();
|
||||
// the runs of each chunk, in order, and the chunks in the order they were first named
|
||||
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
|
||||
let mut order = Vec::new();
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
if r.y1 < r.y0 {
|
||||
continue;
|
||||
}
|
||||
let key = (r.x >> 4, r.z >> 4);
|
||||
by_chunk
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
order.push(key);
|
||||
Vec::new()
|
||||
})
|
||||
.push(i as u32);
|
||||
}
|
||||
let mut filter_memo: Vec<Memo> = Vec::new();
|
||||
let mut tracked_memo = Memo(Vec::new());
|
||||
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
|
||||
let mut changed: Vec<bool> = Vec::new();
|
||||
for key in order {
|
||||
let Some(chunk) = self.chunks.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
|
||||
let max_y = chunk.max_y();
|
||||
let list = &by_chunk[&key];
|
||||
changed.clear();
|
||||
changed.resize(sections, false);
|
||||
values.clear();
|
||||
values.resize_with(sections, || None);
|
||||
let mut any = 0u64;
|
||||
for &i in list {
|
||||
let r = runs[i as usize];
|
||||
let (lx, lz) = (r.x & 15, r.z & 15);
|
||||
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
|
||||
let mut y = y0;
|
||||
while y <= y1 {
|
||||
let s = ((y - min_y) >> 4) as usize;
|
||||
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
|
||||
let vals = values[s].get_or_insert_with(|| {
|
||||
let mut v = Box::new([0u32; 4096]);
|
||||
chunk.sections[s].unpack(&mut v);
|
||||
v
|
||||
});
|
||||
for yy in y..=top {
|
||||
let at = index(lx, yy, lz);
|
||||
let old = vals[at];
|
||||
if old == r.state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = r.only {
|
||||
let f = f as usize;
|
||||
if filter_memo.len() <= f {
|
||||
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
|
||||
}
|
||||
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
vals[at] = r.state;
|
||||
changed[s] = true;
|
||||
any += 1;
|
||||
if tracked_memo.get(old, &mut tracked)
|
||||
|| tracked_memo.get(r.state, &mut tracked)
|
||||
{
|
||||
out.tracked.push(((r.x, yy, r.z), old, r.state));
|
||||
}
|
||||
}
|
||||
y = top + 1;
|
||||
}
|
||||
}
|
||||
if any == 0 {
|
||||
continue;
|
||||
}
|
||||
out.changed += any;
|
||||
out.chunks += 1;
|
||||
let chunk =
|
||||
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
|
||||
for s in 0..sections {
|
||||
if changed[s]
|
||||
&& let Some(v) = &values[s]
|
||||
{
|
||||
chunk.replace_blocks(s, v);
|
||||
}
|
||||
}
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.modified.insert(key);
|
||||
if self.block_log.is_some() {
|
||||
self.bulk_log.insert(key);
|
||||
}
|
||||
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
|
||||
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
|
||||
for ds in -1..=1 {
|
||||
let sy = s as i32 + ds;
|
||||
if !(0..sections as i32).contains(&sy) {
|
||||
continue;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::Chunk;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
// stone up to y 63
|
||||
for z in -16..32 {
|
||||
for x in -16..32 {
|
||||
for y in -64..64 {
|
||||
w.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
w.dirty_light.clear();
|
||||
w.dirty_sections.clear();
|
||||
w.block_log = Some(Vec::new());
|
||||
w
|
||||
}
|
||||
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
|
||||
Run {
|
||||
x,
|
||||
z,
|
||||
y0,
|
||||
y1,
|
||||
state,
|
||||
only,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_change_their_blocks_in_order() {
|
||||
let mut w = world();
|
||||
let runs = [
|
||||
run(3, 4, 10, 40, 0, None),
|
||||
run(3, 4, 10, 12, 5, None),
|
||||
// only where it is air: over the stone, and the three blocks of 5 stay
|
||||
run(3, 4, 0, 80, 7, Some(0)),
|
||||
run(-1, 20, -70, 400, 9, None),
|
||||
];
|
||||
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
|
||||
assert_eq!(w.block(3, 9, 4).0, 1);
|
||||
assert_eq!(w.block(3, 11, 4).0, 5);
|
||||
assert_eq!(w.block(3, 13, 4).0, 7);
|
||||
assert_eq!(w.block(3, 70, 4).0, 7);
|
||||
assert_eq!(w.block(3, 81, 4).0, 0);
|
||||
assert_eq!(w.block(3, 0, 4).0, 1);
|
||||
assert_eq!(w.block(-1, -64, 20).0, 9);
|
||||
assert_eq!(w.block(-1, 319, 20).0, 9);
|
||||
assert_eq!(w.block(0, 30, 20).0, 1);
|
||||
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
|
||||
assert_eq!(out.changed, 31 + 3 + 45 + 384);
|
||||
assert_eq!(out.chunks, 2);
|
||||
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
|
||||
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
|
||||
assert!(w.dirty_sections.contains(&(1, 4, 1)));
|
||||
// the per-block log is left to the blocks set one at a time
|
||||
assert!(w.block_log.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|
||||
|_, _| true,
|
||||
|_| false,
|
||||
);
|
||||
assert_eq!(out.changed, 0);
|
||||
assert_eq!(out.chunks, 0);
|
||||
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_states_are_reported() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
|
||||
assert_eq!(out.tracked.len(), 6);
|
||||
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
|
||||
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heightmaps_follow_the_edit() {
|
||||
let mut w = world();
|
||||
let air = |s: u32| s == 0;
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
|
||||
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
|
||||
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_follow_each_chunks_own_layout() {
|
||||
use crate::chunk::ColumnLayout;
|
||||
// a data pack's taller dimension: -128..=511
|
||||
let tall = ColumnLayout::new(-128, 640).unwrap();
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
|
||||
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
|
||||
assert_eq!(out.changed, 640);
|
||||
let chunk = &w.chunks[&(0, 0)];
|
||||
assert_eq!(chunk.block(2, -128, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, -65, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 320, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 511, 3).0, 4);
|
||||
assert!(w.dirty_sections.contains(&(0, 39, 0)));
|
||||
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
|
||||
}
|
||||
}
|
||||
@@ -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,35 +884,68 @@ 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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
|
||||
let items: Value = serde_json::from_slice(&bytes)?;
|
||||
for (item, components) in items.as_object().into_iter().flatten() {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
|
||||
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
|
||||
let Ok(def) = std::fs::read(file) else {
|
||||
continue;
|
||||
};
|
||||
let def: Value = serde_json::from_slice(&def)?;
|
||||
if let Some(asset) = def["asset_id"].as_str() {
|
||||
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
|
||||
renderer
|
||||
.pot_patterns
|
||||
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +958,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 +1259,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 +1278,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 +1348,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 +1369,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 +1402,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 +1427,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 +1453,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 +1496,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 +1540,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 +1569,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 +1598,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 +1658,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 +1677,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 +1695,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 +1713,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 +1724,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 +1745,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 +1778,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 +2105,6 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &base_sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2462,7 +2304,6 @@ mod tests {
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(false),
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
@@ -2471,55 +2312,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 +2340,6 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2587,8 +2378,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 +2391,7 @@ mod tests {
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
@@ -2653,7 +2444,6 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &first_sign,
|
||||
@@ -2762,7 +2552,6 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2784,131 +2573,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,43 +158,25 @@ 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
|
||||
// absent. Vanilla's brewing recipes carry none; a pack's can.
|
||||
let mods: Vec<String> = {
|
||||
let mut mods = horizon_items::content_namespaces::mod_ids(root);
|
||||
if !mods.iter().any(|m| m == "minecraft") {
|
||||
mods.push("minecraft".to_owned());
|
||||
}
|
||||
mods
|
||||
};
|
||||
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") {
|
||||
@@ -209,11 +191,6 @@ impl Brewing {
|
||||
None => sources.push((id.to_owned(), v)),
|
||||
}
|
||||
}
|
||||
// The gate answers the merged file, as the crafting loader's does:
|
||||
// a false-conditioned pack file leaves the lower one gone too.
|
||||
// The data's recipe filters, over the final set.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
sources.retain_mut(|(id, v)| conditions_hold(v) && filters.apply(id, v));
|
||||
sources.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut recipes = Vec::new();
|
||||
for (id, v) in sources {
|
||||
|
||||
@@ -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]);
|
||||
@@ -539,19 +501,6 @@ impl Camera {
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.align_with_entity_distance(entity, partial, camera_type, |distance| distance, clip);
|
||||
}
|
||||
/// Adjust the detached distance after entity/mount scaling, before collision clipping.
|
||||
/// Only finite overrides in 0..=1024 blocks are accepted (within the game's ray budget).
|
||||
/// First-person views do not call `adjust_distance`.
|
||||
pub fn align_with_entity_distance(
|
||||
&mut self,
|
||||
entity: &CameraEntity,
|
||||
partial: f32,
|
||||
camera_type: CameraType,
|
||||
adjust_distance: impl FnOnce(f32) -> f32,
|
||||
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
|
||||
) {
|
||||
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
|
||||
@@ -579,14 +528,10 @@ impl Camera {
|
||||
mount_scale = s;
|
||||
mount_distance = d as f32;
|
||||
}
|
||||
let vanilla = java_max(scale * distance, mount_scale * mount_distance);
|
||||
let adjusted = adjust_distance(vanilla);
|
||||
let distance = if adjusted.is_finite() && (0.0..=1024.0).contains(&adjusted) {
|
||||
adjusted
|
||||
} else {
|
||||
vanilla
|
||||
};
|
||||
let zoom = self.max_zoom(distance, &mut clip);
|
||||
let zoom = self.max_zoom(
|
||||
java_max(scale * distance, mount_scale * mount_distance),
|
||||
&mut clip,
|
||||
);
|
||||
self.move_by(-zoom, 0.0, 0.0);
|
||||
} else if let Some(LivingCamera {
|
||||
sleeping: Some(bed),
|
||||
@@ -1024,100 +969,6 @@ mod tests {
|
||||
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_override_keeps_collision_clipping() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: Some(LivingCamera {
|
||||
scale: 2.0,
|
||||
camera_distance: 3.0,
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: Some((3.0, 4.0)),
|
||||
};
|
||||
for kind in [CameraType::ThirdPersonBack, CameraType::ThirdPersonFront] {
|
||||
let mut camera = Camera::default();
|
||||
let mut calls = 0;
|
||||
let mut rays = 0;
|
||||
let z = if kind.is_mirrored() { 1.0 } else { -1.0 };
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] - z * 8.0).abs() < 1e-5);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
kind,
|
||||
|distance| {
|
||||
calls += 1;
|
||||
assert_eq!(distance, 12.0);
|
||||
8.0
|
||||
},
|
||||
|_, _| {
|
||||
rays += 1;
|
||||
Some([0.0, 0.0, z])
|
||||
},
|
||||
);
|
||||
assert_eq!((calls, rays), (1, 8));
|
||||
assert!((camera.position[2] - z).abs() < 0.2);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn detached_distance_rejects_invalid_values_and_accepts_zero() {
|
||||
let entity = CameraEntity {
|
||||
position: [0.0; 3],
|
||||
previous: [0.0; 3],
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
x_rot_o: 0.0,
|
||||
y_rot_o: 0.0,
|
||||
eye_height: 1.62,
|
||||
living: None,
|
||||
mount: None,
|
||||
};
|
||||
for distance in [f32::NAN, f32::INFINITY, -1.0, 1025.0] {
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| distance,
|
||||
|_, _| None,
|
||||
);
|
||||
assert!((camera.position[2] + 4.0).abs() < 1e-6);
|
||||
}
|
||||
let mut camera = Camera::default();
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::ThirdPersonBack,
|
||||
|_| 0.0,
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
camera.align_with_entity_distance(
|
||||
&entity,
|
||||
1.0,
|
||||
CameraType::FirstPerson,
|
||||
|_| panic!("first person has no detached distance"),
|
||||
|_, _| None,
|
||||
);
|
||||
assert_eq!(camera.position, camera.eye);
|
||||
}
|
||||
#[test]
|
||||
fn fov_modifier_combines_speed_flight_and_bow() {
|
||||
let mut p = FovPlayer {
|
||||
flying: false,
|
||||
|
||||
@@ -29,10 +29,6 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
|
||||
/// and how far left of its place it starts (GUI pixels).
|
||||
const INTRO_TICKS: f32 = 5.0;
|
||||
const INTRO_WIDTH: f32 = 12.0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -237,10 +233,14 @@ impl Tag {
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: f32, added: u64) -> f32 {
|
||||
let mut t = 1.0 - (ticks - added as f32) / 200.0;
|
||||
t = (t * 10.0).clamp(0.0, 1.0);
|
||||
t * t
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
@@ -256,11 +256,6 @@ pub struct Chat {
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
/// The smooth chat extra: a new line slides in from the left while it
|
||||
/// fades in, instead of appearing whole.
|
||||
pub smooth: bool,
|
||||
/// The smooth chat extra: no indicator bar at the left of a line.
|
||||
pub hide_indicator: bool,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
@@ -560,7 +555,7 @@ impl Chat {
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
@@ -574,47 +569,28 @@ impl Chat {
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
// Vanilla's 200-tick fade reads the whole GUI ticks
|
||||
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
|
||||
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(fade_ticks, line.added)
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
};
|
||||
// The smooth chat extra: how far into its intro a line is, 0 at the
|
||||
// tick it was added and 1 once it has arrived, as (alpha, offset x).
|
||||
let intro_of = |line: &Line| {
|
||||
if focused || !self.smooth {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
(eased, (eased - 1.0) * INTRO_WIDTH)
|
||||
};
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| {
|
||||
let (eased, slide) = intro_of(line);
|
||||
(i, line, alpha_of(line) * eased, slide)
|
||||
})
|
||||
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha, slide) in &lines {
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -622,15 +598,9 @@ impl Chat {
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
for &(i, line, alpha, slide) in &lines {
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
@@ -642,7 +612,7 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
@@ -652,9 +622,6 @@ impl Chat {
|
||||
color,
|
||||
));
|
||||
}
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
@@ -691,7 +658,7 @@ impl Chat {
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
|
||||
@@ -39,61 +39,6 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
let mut chat = Chat {
|
||||
smooth: true,
|
||||
..Chat::default()
|
||||
};
|
||||
chat.lines.push(Line {
|
||||
added: 0,
|
||||
content: Text::literal("hi"),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { color, .. } => Some(color >> 24),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The slide is the negative translate the line carries while it moves.
|
||||
let slide = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The tick it arrives: fully faded out, so nothing is drawn.
|
||||
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
|
||||
// Halfway through the intro: left of its place and still faint.
|
||||
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
|
||||
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
|
||||
let faded = text_alpha(&halfway).expect("the line shows halfway through");
|
||||
// Settled: in place and as opaque as vanilla's line.
|
||||
let settled = chat.layout(361, INTRO_TICKS, false);
|
||||
assert_eq!(slide(&settled), None);
|
||||
assert!(text_alpha(&settled).expect("settled") > faded);
|
||||
// The extra off draws the line whole on the tick it arrives, as vanilla.
|
||||
chat.smooth = false;
|
||||
let plain = chat.layout(361, 0.0, false);
|
||||
assert_eq!(slide(&plain), None);
|
||||
assert_eq!(text_alpha(&plain), text_alpha(&settled));
|
||||
// The indicator bar is the -4 to -2 fill at the left of the line.
|
||||
let bar = |draws: &[GuiDraw]| {
|
||||
draws
|
||||
.iter()
|
||||
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
|
||||
};
|
||||
assert!(bar(&plain));
|
||||
chat.hide_indicator = true;
|
||||
assert!(!bar(&chat.layout(361, 0.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
@@ -150,7 +95,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0.0, true) {
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
|
||||
@@ -508,15 +508,6 @@ impl Chunk {
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// Section `s`'s block states replaced whole (a bulk edit): its storage
|
||||
/// compacted, and the heightmaps built again on their next read.
|
||||
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
@@ -644,10 +635,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
|
||||
@@ -336,9 +233,6 @@ pub struct SkullSkin {
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
|
||||
/// texture drawn instead of any skin.
|
||||
pub texture: Option<String>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
@@ -691,64 +585,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(©.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)),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,849 +0,0 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
|
||||
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};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, 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`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.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());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
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.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
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)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
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,
|
||||
kind: Kind,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, 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> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
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()));
|
||||
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`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
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);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
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();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
//! Cloud visibility and flat/fancy cell geometry (`CloudRenderer`).
|
||||
/// `EnvironmentAttributes.CLOUD_HEIGHT`'s default, and the overworld's `visual/cloud_height`.
|
||||
pub const CLOUD_HEIGHT: f64 = 192.33;
|
||||
|
||||
/// Whether the cloud color has any packed alpha (vanilla gates the clouds on it).
|
||||
pub fn has_color(alpha: f32) -> bool {
|
||||
(alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `LevelRenderer`: skip OFF clouds and colors with no packed alpha.
|
||||
pub fn visible(mode: &str, alpha: f32) -> bool {
|
||||
mode != "false" && has_color(alpha)
|
||||
mode != "false" && (alpha.clamp(0.0, 1.0) * 255.0) as u8 > 0
|
||||
}
|
||||
|
||||
/// `buildFlatCell`: bottom plane, in both windings for the culled cloud pipeline.
|
||||
@@ -118,7 +110,7 @@ impl Input<'_> {
|
||||
z -= (z / th).floor() * th;
|
||||
let cell_x = (x / 12.0).floor() as i64;
|
||||
let cell_z = (z / 12.0).floor() as i64;
|
||||
let bottom = (CLOUD_HEIGHT - self.camera.y) as f32;
|
||||
let bottom = (192.33 - self.camera.y) as f32;
|
||||
let key = MeshKey {
|
||||
cell_x,
|
||||
cell_z,
|
||||
|
||||
@@ -199,9 +199,8 @@ where
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker or
|
||||
/// a living entity in leather boots collides as the living entity it is,
|
||||
/// empty-handed.
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
/// collides as the living entity it is, empty-handed.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -237,10 +236,7 @@ impl SceneCollisions<'_, '_> {
|
||||
}
|
||||
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
|
||||
let y = bounds.min_position().y;
|
||||
if !self
|
||||
.subject
|
||||
.is_some_and(|s| s.lava_walker || s.leather_boots)
|
||||
{
|
||||
if !self.subject.is_some_and(|s| s.lava_walker) {
|
||||
return CollisionContext::position(y);
|
||||
}
|
||||
CollisionContext::for_entity(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,66 +3,12 @@
|
||||
//! 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;
|
||||
|
||||
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
|
||||
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
|
||||
/// slots players only take from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModMenuType {
|
||||
pub id: &'static str,
|
||||
pub slots: usize,
|
||||
pub data_slots: usize,
|
||||
pub results: Vec<usize>,
|
||||
}
|
||||
|
||||
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
|
||||
|
||||
/// registers the content mods' menu types, `(id, slots, data slots,
|
||||
/// results)`, a list set once per process (false when it already was).
|
||||
pub fn register_mod_menu_types(
|
||||
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
|
||||
) -> bool {
|
||||
if MOD_MENU_TYPES.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let menus = menus
|
||||
.into_iter()
|
||||
.map(|(id, slots, data_slots, results)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
});
|
||||
MOD_MENU_TYPES.set(menus.collect()).is_ok()
|
||||
}
|
||||
|
||||
/// the content mods' menu types, in registry order.
|
||||
pub fn mod_menu_types() -> &'static [ModMenuType] {
|
||||
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// the mod menu type named `id`, kept out of line: a vanilla menu never
|
||||
/// looks one up.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
|
||||
mod_menu_types().iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
|
||||
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
|
||||
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
|
||||
/// vanilla's only. A Horizon client has the server's (the content identity).
|
||||
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
|
||||
brand == "horizon" || menu.starts_with("minecraft:")
|
||||
}
|
||||
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -250,7 +196,6 @@ impl SkullSkin {
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
texture: crate::skins::profile_texture_nbt(profile),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
@@ -665,15 +610,6 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// a client that isn't Horizon's is sent vanilla's menu types only.
|
||||
#[test]
|
||||
fn a_mod_menu_type_reaches_horizon_clients_only() {
|
||||
assert!(menu_reaches("horizon", "ruby:press"));
|
||||
assert!(!menu_reaches("vanilla", "ruby:press"));
|
||||
assert!(!menu_reaches("fabric", "ruby:press"));
|
||||
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -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,11 +227,14 @@ 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) {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -351,8 +354,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
);
|
||||
crate::logging::flush();
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
@@ -384,30 +386,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
|
||||
@@ -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/`, `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>>>,
|
||||
@@ -583,16 +583,15 @@ pub struct DataStore {
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<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
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `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/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "data_maps" => kind,
|
||||
"recipe" | "loot_table" | "advancement" | "trade_set" | "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,14 +622,12 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
"predicate",
|
||||
"recipe",
|
||||
"recipe_filter",
|
||||
"tags",
|
||||
"trade_set",
|
||||
"villager_trade",
|
||||
@@ -883,15 +880,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(
|
||||
|
||||
@@ -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,44 +315,11 @@ 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> {
|
||||
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
|
||||
// vertex colour, with fog fading both RGB and alpha before additive blending.
|
||||
if in.overlay == 37u {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
@@ -388,21 +327,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
|
||||
|
||||
@@ -141,8 +141,6 @@ pub struct Sky {
|
||||
pub fog_end: f32,
|
||||
/// `visual/cloud_fog_end_distance`.
|
||||
pub cloud_fog_end: f32,
|
||||
/// `visual/cloud_height`.
|
||||
pub cloud_height: f32,
|
||||
}
|
||||
impl Environment {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
@@ -311,10 +309,6 @@ impl Environment {
|
||||
.base
|
||||
.get("minecraft:visual/cloud_fog_end_distance")
|
||||
.map_or(2048.0, |v| v[0]),
|
||||
cloud_height: self
|
||||
.base
|
||||
.get("minecraft:visual/cloud_height")
|
||||
.map_or(crate::clouds::CLOUD_HEIGHT as f32, |v| v[0]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// Positional sounds leave a few static sound channels free for UI sounds
|
||||
/// and others played to the listener (`sound_engine::HEARD_RESERVE`).
|
||||
pub ui_sound_reserve: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// No particles from breaking or mining a block (other particles stay).
|
||||
pub hide_block_particles: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -111,10 +106,6 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
pub chat_indicator: bool,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
@@ -122,8 +113,6 @@ pub struct Extras {
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -149,19 +138,14 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
ui_sound_reserve: false,
|
||||
own_name_tag: false,
|
||||
hide_block_particles: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,19 +213,14 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"uiSoundReserve" => e.ui_sound_reserve = flag(e.ui_sound_reserve),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"hideBlockParticles" => e.hide_block_particles = flag(e.hide_block_particles),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -275,20 +254,15 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.ui_sound_reserve),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.hide_block_particles),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.chat_animation),
|
||||
bool_text(self.chat_indicator),
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
@@ -409,139 +383,64 @@ pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
HideBlockParticles,
|
||||
AutoReconnect,
|
||||
UiSoundReserve,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// A new chat line slides in and fades in (`Chat::layout`).
|
||||
ChatAnimation,
|
||||
/// The coloured bar at the left of a chat line.
|
||||
ChatIndicator,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
|
||||
Item::Sort => "horizon.extras.sort",
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright.tooltip",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
|
||||
Item::Sort => "horizon.extras.sort.tooltip",
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
pub fn caption_text(self) -> String {
|
||||
crate::lang::get_or(self.caption_key(), self.caption())
|
||||
}
|
||||
/// The description in the current language (English when the key is missing).
|
||||
pub fn description_text(self) -> String {
|
||||
crate::lang::get_or(self.description_key(), self.description())
|
||||
}
|
||||
/// The English caption: the fallback of [`Item::caption_text`], and the text of
|
||||
/// `horizon.extras.*` in en_us.json.
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::HideBlockParticles => "Hide block-break particles",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::UiSoundReserve => "Keep channels for UI sounds",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::ChatAnimation => "Smooth chat",
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
pub fn parent(self) -> Option<Item> {
|
||||
use Item::*;
|
||||
Some(match self {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
/// The English description: the fallback of [`Item::description_text`].
|
||||
/// What the row says under its caption.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
@@ -551,15 +450,9 @@ impl Item {
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::HideBlockParticles => {
|
||||
"Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
Item::UiSoundReserve => {
|
||||
"Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks."
|
||||
}
|
||||
Item::MouseTweaks => {
|
||||
"Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot."
|
||||
}
|
||||
@@ -572,19 +465,12 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::ChatAnimation => {
|
||||
"A new chat line slides in from the left while it fades in, instead of appearing whole."
|
||||
}
|
||||
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -595,15 +481,10 @@ impl Item {
|
||||
use crate::graphics_style::Cost;
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::ChatAnimation => Some(Cost::None),
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -613,18 +494,13 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::ChatAnimation => e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -633,8 +509,8 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -645,18 +521,13 @@ impl Item {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::HideBlockParticles => e.hide_block_particles = !e.hide_block_particles,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::UiSoundReserve => e.ui_sound_reserve = !e.ui_sound_reserve,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -678,8 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -705,22 +575,11 @@ mod tests {
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(false));
|
||||
Item::HideBlockParticles.cycle(&mut e, false);
|
||||
assert!(e.hide_block_particles);
|
||||
assert_eq!(Item::HideBlockParticles.switch(&e), Some(true));
|
||||
let only = Extras::parse("hideBlockParticles:true\n");
|
||||
assert!(only.hide_block_particles && !only.own_name_tag);
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,85 @@
|
||||
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
|
||||
//! (the scheduled ticks use it too), and reachable here under its old path.
|
||||
pub use horizon_core::fast_hash::*;
|
||||
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
|
||||
//! keyed by small integers, such as chunk coordinates. Iteration order of
|
||||
//! such maps carries no meaning: the default hasher's is random anyway.
|
||||
use std::hash::{BuildHasherDefault, Hasher};
|
||||
|
||||
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct FxHasher {
|
||||
hash: u64,
|
||||
}
|
||||
impl FxHasher {
|
||||
#[inline]
|
||||
fn add(&mut self, word: u64) {
|
||||
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
|
||||
}
|
||||
}
|
||||
impl Hasher for FxHasher {
|
||||
#[inline]
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
let (words, rest) = bytes.as_chunks::<8>();
|
||||
for word in words {
|
||||
self.add(u64::from_le_bytes(*word));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
let mut word = [0u8; 8];
|
||||
word[..rest.len()].copy_from_slice(rest);
|
||||
self.add(u64::from_le_bytes(word));
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn write_u8(&mut self, i: u8) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u16(&mut self, i: u16) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u32(&mut self, i: u32) {
|
||||
self.add(u64::from(i));
|
||||
}
|
||||
#[inline]
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.add(i);
|
||||
}
|
||||
#[inline]
|
||||
fn write_usize(&mut self, i: usize) {
|
||||
self.add(i as u64);
|
||||
}
|
||||
#[inline]
|
||||
fn write_i32(&mut self, i: i32) {
|
||||
self.add(u64::from(i as u32));
|
||||
}
|
||||
#[inline]
|
||||
fn finish(&self) -> u64 {
|
||||
// The multiply leaves the low bits weakest; hash tables use them.
|
||||
self.hash.rotate_left(26)
|
||||
}
|
||||
}
|
||||
pub type FxBuild = BuildHasherDefault<FxHasher>;
|
||||
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
|
||||
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearby_chunks_spread_over_buckets() {
|
||||
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
for x in -40..40 {
|
||||
for z in -40..40 {
|
||||
map.insert((x, z), x * z);
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 6400);
|
||||
assert_eq!(map[&(-7, 13)], -91);
|
||||
let mut low = std::collections::HashSet::new();
|
||||
for x in 0..64 {
|
||||
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
|
||||
}
|
||||
assert!(low.len() > 32, "low bits vary: {}", low.len());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -112,8 +112,6 @@ struct Region {
|
||||
cursor: (u32, u32),
|
||||
row: u32,
|
||||
pending: Vec<Patch>,
|
||||
/// A glyph did not fit since the last `Font::begin_frame`.
|
||||
full: bool,
|
||||
}
|
||||
struct Cache {
|
||||
/// Every font by id, the default one first (the common lookup).
|
||||
@@ -548,7 +546,6 @@ impl Region {
|
||||
self.row = 0;
|
||||
}
|
||||
if y + h > region.1 {
|
||||
self.full = true;
|
||||
return None;
|
||||
}
|
||||
let (ax, ay) = (x, region.0 + y);
|
||||
@@ -606,7 +603,6 @@ impl Font {
|
||||
cursor: (0, 0),
|
||||
row: 0,
|
||||
pending: Vec::new(),
|
||||
full: false,
|
||||
},
|
||||
random: horizon_core::random::LegacyRandom::new(
|
||||
std::time::SystemTime::now()
|
||||
@@ -673,19 +669,8 @@ impl Font {
|
||||
}
|
||||
cache.region.cursor = (0, 0);
|
||||
cache.region.row = 0;
|
||||
cache.region.full = false;
|
||||
self.generation.set(next_generation());
|
||||
}
|
||||
/// Call once per frame before any text is laid out. When the dynamic
|
||||
/// region ran out of room (vanilla's glyph textures grow instead), the
|
||||
/// glyphs are forgotten so they are rasterized again from the start; it
|
||||
/// waits for the frame boundary because quads already queued hold the old
|
||||
/// rects.
|
||||
pub fn begin_frame(&self) {
|
||||
if self.cache.borrow().region.full {
|
||||
self.reset_cache();
|
||||
}
|
||||
}
|
||||
/// Changes whenever a glyph may come out different: another font, a
|
||||
/// cache reset (glyphs move in the atlas) or other font options. Text laid
|
||||
/// out under one epoch can be kept until it changes.
|
||||
@@ -721,16 +706,7 @@ impl Font {
|
||||
if let Some(g) = set.glyphs.get(&c) {
|
||||
return *g;
|
||||
}
|
||||
let was_full = std::mem::replace(&mut region.full, false);
|
||||
let found = self.resolve(set, region, c);
|
||||
// No room: the missing glyph for now, not remembered, so the
|
||||
// character is tried again once `begin_frame` cleared the region.
|
||||
let overflowed = std::mem::replace(&mut region.full, was_full);
|
||||
region.full |= overflowed;
|
||||
if overflowed {
|
||||
return self.missing;
|
||||
}
|
||||
let glyph = found.unwrap_or(self.missing);
|
||||
let glyph = self.resolve(set, region, c).unwrap_or(self.missing);
|
||||
set.glyphs.insert(c, glyph);
|
||||
glyph
|
||||
}
|
||||
@@ -947,16 +923,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
|
||||
@@ -1452,56 +1418,4 @@ mod tests {
|
||||
font.reset_cache();
|
||||
assert_eq!(font.glyph('a').advance, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_glyph_region_recovers_at_the_next_frame() {
|
||||
// Two 8x8 glyphs from a sheet outside the atlas; the region holds one row.
|
||||
let sheet = png(16, 8, |x, _| x % 8 < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let root = root(
|
||||
"full",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
// A 10 wide atlas fits one 8 pixel glyph per row; the region two rows.
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
let a = font.glyph('a');
|
||||
assert!(a.rect.is_some());
|
||||
// No room for 'b': missing for now, and asked again next time.
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert!(font.glyph('b').rect.is_none());
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
font.begin_frame();
|
||||
assert_ne!(font.epoch(), epoch);
|
||||
let b = font.glyph('b');
|
||||
assert!(b.rect.is_some());
|
||||
assert_eq!(b.advance, a.advance);
|
||||
// Nothing overflowed since: the next frame keeps the glyphs.
|
||||
let epoch = font.epoch();
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_without_a_provider_does_not_reset_the_region() {
|
||||
let sheet = png(8, 8, |x, _| x < 4);
|
||||
let default = br#"{"providers":[{"type":"bitmap","file":"pack:font/s.png","ascent":7,"chars":["a"]}]}"#;
|
||||
let root = root(
|
||||
"nofill",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", default),
|
||||
("assets/pack/textures/font/s.png", &sheet),
|
||||
],
|
||||
);
|
||||
let font = Font::load(&root, &HashMap::new(), &[], (10, 64), (0, 8), white());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let epoch = font.epoch();
|
||||
assert!(font.glyph('z').rect.is_none());
|
||||
font.begin_frame();
|
||||
assert_eq!(font.epoch(), epoch);
|
||||
}
|
||||
}
|
||||
+525
-2661
File diff suppressed because it is too large.
Load diff
@@ -299,7 +299,6 @@ impl SpriteAnimator {
|
||||
uploads: &mut Uploads,
|
||||
tick: u64,
|
||||
active: Option<&[u64]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if self.collect(tick, active) == 0 {
|
||||
return;
|
||||
@@ -342,7 +341,7 @@ impl SpriteAnimator {
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Animated sprites"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Animated sprites")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
for group in &mut self.groups {
|
||||
@@ -735,7 +734,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()
|
||||
}
|
||||
@@ -860,7 +859,7 @@ mod tests {
|
||||
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
|
||||
animator.animate(&mut encoder, &mut uploads, tick, None, None);
|
||||
animator.animate(&mut encoder, &mut uploads, tick, None);
|
||||
writes.push(
|
||||
animator
|
||||
.sprites
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,224 +0,0 @@
|
||||
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
|
||||
//! a grid of cells around the camera, each holding the colour of the light sources that
|
||||
//! reach it, averaged by how near each is, which the material stage reads through the
|
||||
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
|
||||
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
|
||||
//!
|
||||
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
|
||||
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
|
||||
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use glam::{DVec3, Vec3};
|
||||
|
||||
use super::SectionKey;
|
||||
|
||||
/// Cells along each side of the grid.
|
||||
pub const SIZE: u32 = 64;
|
||||
/// The side of a cell, in blocks: the grid spans 128 blocks.
|
||||
pub const CELL: f32 = 2.0;
|
||||
/// The grid moves in steps of this many blocks.
|
||||
const STEP: i32 = 16;
|
||||
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
|
||||
const MAX_SOURCES: usize = 2048;
|
||||
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
|
||||
const COLOR_RANGE: f32 = 4.0;
|
||||
|
||||
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
|
||||
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
|
||||
|
||||
pub struct BlockLight {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
|
||||
pub grid: wgpu::Buffer,
|
||||
table: LightTable,
|
||||
/// The world corner and the sources' revision the texture was last built for.
|
||||
built: Option<([i32; 3], u64)>,
|
||||
}
|
||||
|
||||
impl BlockLight {
|
||||
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack block light colours"),
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: SIZE,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D3,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
let grid = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack block light grid"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
grid,
|
||||
table,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
|
||||
/// `sections`' sources) changed.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
revision: u64,
|
||||
) {
|
||||
let half = (SIZE as f32 * CELL / 2.0) as f64;
|
||||
let origin = ((camera - half) / f64::from(STEP))
|
||||
.floor()
|
||||
.as_ivec3()
|
||||
.to_array()
|
||||
.map(|v| v * STEP);
|
||||
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
|
||||
queue.write_buffer(
|
||||
&self.grid,
|
||||
0,
|
||||
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
|
||||
);
|
||||
if self.built == Some((origin, revision)) {
|
||||
return;
|
||||
}
|
||||
self.built = Some((origin, revision));
|
||||
let cells = build(&self.table, origin, camera, sections);
|
||||
queue.write_texture(
|
||||
self.texture.as_image_copy(),
|
||||
&cells,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
self.texture.size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
|
||||
fn build(
|
||||
table: &LightTable,
|
||||
origin: [i32; 3],
|
||||
camera: DVec3,
|
||||
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
) -> Vec<u8> {
|
||||
let n = SIZE as usize;
|
||||
let mut cells = vec![0u8; n * n * n * 4];
|
||||
if table.is_empty() {
|
||||
return cells;
|
||||
}
|
||||
let span = SIZE as i32 * CELL as i32;
|
||||
// Sections the grid or the light reaching into it (15 blocks) touches.
|
||||
let lo = origin.map(|v| (v - 15).div_euclid(16));
|
||||
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
|
||||
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
|
||||
for (key, lights) in sections {
|
||||
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
|
||||
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
|
||||
continue;
|
||||
}
|
||||
for &(pos, state) in lights {
|
||||
let Some(&(color, level)) = table.get(&state) else {
|
||||
continue;
|
||||
};
|
||||
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
|
||||
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
|
||||
sources.push((
|
||||
local,
|
||||
color,
|
||||
f32::from(level),
|
||||
center.distance_squared(camera),
|
||||
));
|
||||
}
|
||||
}
|
||||
if sources.len() > MAX_SOURCES {
|
||||
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
|
||||
sources.truncate(MAX_SOURCES);
|
||||
}
|
||||
let mut sum = vec![[0.0f32; 4]; n * n * n];
|
||||
for (at, color, reach, _) in sources {
|
||||
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
|
||||
let last = ((at + reach) / CELL)
|
||||
.ceil()
|
||||
.min(Vec3::splat(SIZE as f32 - 1.0))
|
||||
.as_ivec3();
|
||||
for z in first.z..=last.z {
|
||||
for y in first.y..=last.y {
|
||||
for x in first.x..=last.x {
|
||||
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
|
||||
let d = cell.distance(at);
|
||||
if d >= reach {
|
||||
continue;
|
||||
}
|
||||
// As block light falls off: strongest at the source, gone at its level.
|
||||
let w = (1.0 - d / reach).powi(2);
|
||||
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
|
||||
s[0] += color[0] * w;
|
||||
s[1] += color[1] * w;
|
||||
s[2] += color[2] * w;
|
||||
s[3] += w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
|
||||
if s[3] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
for i in 0..3 {
|
||||
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
|
||||
}
|
||||
cell[3] = byte(s[3]);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
|
||||
let mut table = LightTable::default();
|
||||
table.insert(7, ([2.0, 1.0, 0.0], 14));
|
||||
let mut sections = FxHashMap::default();
|
||||
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
|
||||
let sy = (64 - crate::chunk::MIN_Y) / 16;
|
||||
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
|
||||
let camera = DVec3::new(8.0, 64.0, 8.0);
|
||||
let origin = [-64, 0, -64];
|
||||
let cells = build(&table, origin, camera, §ions);
|
||||
let n = SIZE as usize;
|
||||
let at = |p: [i32; 3]| {
|
||||
let c = p
|
||||
.iter()
|
||||
.zip(origin)
|
||||
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
|
||||
.collect::<Vec<_>>();
|
||||
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
|
||||
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
|
||||
};
|
||||
let near = at([8, 64, 8]);
|
||||
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
|
||||
assert_eq!(&near[..3], &[128, 64, 0]);
|
||||
assert!(near[3] > 150);
|
||||
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
|
||||
assert!(
|
||||
build(&LightTable::default(), origin, camera, §ions)
|
||||
.iter()
|
||||
.all(|&b| b == 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -528,11 +528,7 @@ impl Cull {
|
||||
|
||||
/// Between the phases (`InShader`): draw the tested boxes in a pass of
|
||||
/// their own that reads phase 1's depth, bound by `bind_depth`.
|
||||
pub fn test_pass(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
|
||||
(&self.group, &self.depth_group)
|
||||
else {
|
||||
@@ -543,7 +539,6 @@ impl Cull {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Section culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("Occlusion test")),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
@@ -585,25 +580,23 @@ impl Cull {
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's results to read back, started by calling it right after the frame's
|
||||
/// submission (on the submit thread, when the frame goes there).
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
/// After the frame's submission: start reading its results back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(i) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let r = &self.readbacks[i];
|
||||
let state = r.state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
|
||||
r.buffer
|
||||
.slice(..r.slots.len() as u64 * 4)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn make_group(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,12 +20,6 @@ const MATERIALS: usize = 10;
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
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]) {
|
||||
@@ -85,11 +79,6 @@ pub(super) struct EntityBatch {
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
|
||||
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
|
||||
self.combined.extend_from_slice(shadow_only);
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
&mut self,
|
||||
entities: &[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;
|
||||
@@ -260,20 +258,13 @@ impl EntityOutline {
|
||||
frame: &wgpu::TextureView,
|
||||
entity_group: &wgpu::BindGroup,
|
||||
indices: &wgpu::Buffer,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
|
||||
return;
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
{
|
||||
let mut pass = begin(
|
||||
encoder,
|
||||
"Entity outline",
|
||||
&t.outline,
|
||||
clear,
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
|
||||
pass.set_pipeline(&self.silhouette);
|
||||
pass.set_bind_group(0, entity_group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -289,7 +280,7 @@ impl EntityOutline {
|
||||
("Entity outline blit", frame, wgpu::LoadOp::Load),
|
||||
];
|
||||
for (i, (label, view, load)) in steps.into_iter().enumerate() {
|
||||
let mut pass = begin(encoder, label, view, load, timing.as_deref_mut());
|
||||
let mut pass = begin(encoder, label, view, load);
|
||||
pass.set_pipeline(&self.passes[i]);
|
||||
pass.set_bind_group(0, &t.groups[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
@@ -302,7 +293,6 @@ fn begin<'e>(
|
||||
label: &str,
|
||||
view: &wgpu::TextureView,
|
||||
load: wgpu::LoadOp<wgpu::Color>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -315,7 +305,6 @@ fn begin<'e>(
|
||||
})];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -64,13 +62,7 @@ pub struct Call {
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
|
||||
/// the quads with no cutout first, through a shader that never discards, then the rest.
|
||||
pub solid_opaque: wgpu::RenderPipeline,
|
||||
pub solid_cutout: wgpu::RenderPipeline,
|
||||
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
|
||||
pub solid_mirror: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
@@ -109,9 +101,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 +210,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 +218,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 +242,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,18 +277,16 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
impl Flat {
|
||||
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
|
||||
/// `pipeline(layout, label, fragment entry, blend, cull)` builds a
|
||||
/// terrain pipeline as the per-section ones, without the origin buffer.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
@@ -321,7 +297,6 @@ impl Flat {
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
&str,
|
||||
&str,
|
||||
Option<wgpu::BlendState>,
|
||||
Option<wgpu::Face>,
|
||||
) -> wgpu::RenderPipeline,
|
||||
@@ -379,35 +354,11 @@ impl Flat {
|
||||
let storage_usage = wgpu::BufferUsages::STORAGE;
|
||||
let back = Some(wgpu::Face::Back);
|
||||
Self {
|
||||
solid: pipeline(
|
||||
&layout,
|
||||
"Terrain solid (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_opaque: pipeline(
|
||||
&layout,
|
||||
"Terrain solid opaque (flat)",
|
||||
"vs_terrain_flat_opaque",
|
||||
"fs_opaque",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid_cutout: pipeline(
|
||||
&layout,
|
||||
"Terrain solid cutout (flat)",
|
||||
"vs_terrain_flat_cutout",
|
||||
"fs_solid",
|
||||
None,
|
||||
back,
|
||||
),
|
||||
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
|
||||
// The mirror flips the winding: its solid terrain culls the front.
|
||||
solid_mirror: pipeline(
|
||||
&layout,
|
||||
"Terrain solid mirrored (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_solid_mirror",
|
||||
None,
|
||||
Some(wgpu::Face::Front),
|
||||
@@ -415,7 +366,6 @@ impl Flat {
|
||||
translucent: pipeline(
|
||||
&layout,
|
||||
"Terrain translucent (flat)",
|
||||
"vs_terrain_flat",
|
||||
"fs_translucent",
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
back,
|
||||
@@ -456,7 +406,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,13 +487,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;
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
@@ -581,13 +526,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 +537,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 +601,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,
|
||||
@@ -733,19 +668,15 @@ impl Flat {
|
||||
.collect();
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
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");
|
||||
@@ -769,44 +700,19 @@ impl Flat {
|
||||
true
|
||||
}
|
||||
/// After occlusion culling's test: copies the phase 2 draws it saw.
|
||||
pub fn flatten_phase2(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
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 +833,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 +867,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 +882,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 +909,10 @@ mod tests {
|
||||
2,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
@@ -1054,7 +920,6 @@ mod tests {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
shadow_calls: [4, 4],
|
||||
pass_calls: [1, 0],
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
@@ -1080,7 +945,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 +1024,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.
|
||||
|
||||
@@ -1,572 +1,9 @@
|
||||
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
|
||||
//! makes a frame halfway between each two real ones. The world, without the hand and
|
||||
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
|
||||
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
|
||||
//! present thread shows the made frame, then the real one half a real frame later.
|
||||
//!
|
||||
//! The renderer draws into frames of its own while this is on; the present thread
|
||||
//! copies each to the swapchain, which it alone acquires and presents.
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow and interpolation; the setting that
|
||||
//! runs them comes next.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
|
||||
const FRAMES: usize = 6;
|
||||
|
||||
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
|
||||
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
|
||||
/// showing what moved on its own in blue (its motion vectors in red and green).
|
||||
pub(super) fn generated_only() -> bool {
|
||||
debug().is_some()
|
||||
}
|
||||
|
||||
fn debug() -> Option<&'static str> {
|
||||
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
DEBUG
|
||||
.get_or_init(|| {
|
||||
std::env::var("HORIZON_FRAMEGEN_DEBUG")
|
||||
.ok()
|
||||
.filter(|v| v == "generated" || v == "motion")
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
pub struct FrameGen {
|
||||
pub on: bool,
|
||||
/// The present thread failed to show a frame (or died): until the renderer has
|
||||
/// acquired a frame itself, the normal path runs and handles the surface.
|
||||
pub paused: bool,
|
||||
gpu: Option<Gpu>,
|
||||
presenter: Option<Presenter>,
|
||||
/// When the last real frame was handed over, and the time between real frames.
|
||||
last: Option<Instant>,
|
||||
interval: Duration,
|
||||
/// The last frame's unjittered `view_proj` and camera.
|
||||
previous: Option<(DMat4, DVec3)>,
|
||||
/// The last frame's hand vertices and the bob and view they were drawn with.
|
||||
hand: Vec<crate::mesher::Vertex>,
|
||||
hand_view: Mat4,
|
||||
/// Offscreen, the frame the made frame is drawn into.
|
||||
offscreen_generated: Option<wgpu::Texture>,
|
||||
/// Motion vectors for what moves on its own, made on the first frame.
|
||||
motion: Option<motion::ObjectMotion>,
|
||||
/// Real frames and made ones handed to the screen since `shown_since`, and the share
|
||||
/// of real frames that had a made frame over the last half second (the F3 screen's),
|
||||
/// `None` while off.
|
||||
real: u32,
|
||||
made: u32,
|
||||
shown_since: Instant,
|
||||
pub made_share: Option<f32>,
|
||||
}
|
||||
|
||||
/// What runs on the GPU for one screen size.
|
||||
struct Gpu {
|
||||
size: [u32; 2],
|
||||
flow: optical_flow::OpticalFlow,
|
||||
interpolation: interpolation::Interpolation,
|
||||
/// This frame's world at the screen's size, and bindings that copy it or the
|
||||
/// interpolation's output (`Post::finish_with`).
|
||||
world: wgpu::TextureView,
|
||||
world_group: wgpu::BindGroup,
|
||||
output_group: wgpu::BindGroup,
|
||||
/// The next interpolation has no previous frame.
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
/// This frame's camera, for the interpolation.
|
||||
pub struct Camera {
|
||||
/// The world's projection this frame (with TAA's jitter) and without the jitter.
|
||||
pub jittered: Mat4,
|
||||
pub view_proj: Mat4,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub render_size: [u32; 2],
|
||||
pub jitter: [f32; 2],
|
||||
/// This frame's unjittered clip space to the previous frame's, `None` without one.
|
||||
pub reproject: Option<Mat4>,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
pub fov_y: f32,
|
||||
}
|
||||
|
||||
impl FrameGen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
on: false,
|
||||
paused: false,
|
||||
gpu: None,
|
||||
presenter: None,
|
||||
last: None,
|
||||
interval: Duration::from_millis(16),
|
||||
previous: None,
|
||||
hand: Vec::new(),
|
||||
hand_view: Mat4::IDENTITY,
|
||||
offscreen_generated: None,
|
||||
motion: None,
|
||||
real: 0,
|
||||
made: 0,
|
||||
shown_since: Instant::now(),
|
||||
made_share: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends frame generation's frames and present thread: before the surface is
|
||||
/// reconfigured or the renderer acquires from it itself.
|
||||
pub fn stop(&mut self) {
|
||||
self.presenter = None;
|
||||
self.made_share = None;
|
||||
(self.real, self.made) = (0, 0);
|
||||
self.last = None;
|
||||
self.previous = None;
|
||||
self.hand.clear();
|
||||
if let Some(motion) = &mut self.motion {
|
||||
motion.reset();
|
||||
}
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand for the made frame, appended to `out`: each vertex halfway between where
|
||||
/// it was on screen last frame and where it is now (the hand swings and bobs on its
|
||||
/// own, so the world's interpolation can't move it), in this frame's space
|
||||
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
|
||||
/// shape (another item, the first frame).
|
||||
pub fn halfway_hand(
|
||||
&mut self,
|
||||
hand: &[crate::mesher::Vertex],
|
||||
view: Mat4,
|
||||
out: &mut Vec<crate::mesher::Vertex>,
|
||||
) {
|
||||
let same = self.hand.len() == hand.len()
|
||||
&& self
|
||||
.hand
|
||||
.iter()
|
||||
.zip(hand)
|
||||
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
|
||||
if same {
|
||||
let back = view.inverse();
|
||||
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
|
||||
let then = self.hand_view.transform_point3(before.pos.into());
|
||||
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
|
||||
crate::mesher::Vertex {
|
||||
pos: back.transform_point3(mid).into(),
|
||||
..*now
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
out.extend_from_slice(hand);
|
||||
}
|
||||
self.hand.clear();
|
||||
self.hand.extend_from_slice(hand);
|
||||
self.hand_view = view;
|
||||
}
|
||||
|
||||
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
|
||||
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
|
||||
/// entities' textures. Before `generate`, every frame.
|
||||
pub fn track_entities(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
atlas: &wgpu::TextureView,
|
||||
entities: &[crate::mesher::Vertex],
|
||||
spans: &[(u32, u64)],
|
||||
camera: DVec3,
|
||||
) {
|
||||
self.motion
|
||||
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
|
||||
.track(entities, spans, camera);
|
||||
}
|
||||
|
||||
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
|
||||
/// (relative to `camera`); `None` on the first frame.
|
||||
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
|
||||
let current = view_proj.as_dmat4();
|
||||
let previous = self.previous.replace((current, camera));
|
||||
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
|
||||
}
|
||||
|
||||
/// Frames to draw the real and the made frame into, from the present thread's
|
||||
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
|
||||
/// one of its own. `None` once the present thread failed or died: it is stopped and
|
||||
/// frame generation `paused`.
|
||||
pub fn frames(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Option<&Arc<wgpu::Surface<'static>>>,
|
||||
offscreen: Option<&wgpu::Texture>,
|
||||
window: &Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let Some(surface) = surface else {
|
||||
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
|
||||
if self
|
||||
.offscreen_generated
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != real.size())
|
||||
{
|
||||
self.offscreen_generated = Some(frame(device, config));
|
||||
}
|
||||
let generated = self.offscreen_generated.clone().expect("made above");
|
||||
return Some((real, generated));
|
||||
};
|
||||
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
|
||||
self.presenter = None;
|
||||
}
|
||||
let presenter = self.presenter.get_or_insert_with(|| {
|
||||
Presenter::start(device, queue, surface.clone(), window.clone(), config)
|
||||
});
|
||||
// A closed channel: the thread died.
|
||||
let pair = if presenter.failed() {
|
||||
None
|
||||
} else {
|
||||
presenter.free.recv().ok().zip(presenter.free.recv().ok())
|
||||
};
|
||||
if pair.is_none() {
|
||||
self.stop();
|
||||
self.paused = true;
|
||||
}
|
||||
pair
|
||||
}
|
||||
|
||||
/// The world's target at the screen's size this frame, where `Post::finish` draws.
|
||||
pub fn world(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
post: &super::world_post::Post,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> wgpu::TextureView {
|
||||
let size = [config.width, config.height];
|
||||
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
|
||||
let world = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation world"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// COPY_SRC: the interpolation keeps it as the previous frame.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let interpolation = interpolation::Interpolation::new(device, size);
|
||||
self.gpu = Some(Gpu {
|
||||
size,
|
||||
flow: optical_flow::OpticalFlow::new(device, size),
|
||||
world_group: post.bind(device, &world),
|
||||
output_group: post.bind(device, interpolation.output()),
|
||||
interpolation,
|
||||
world,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
self.gpu.as_ref().expect("made above").world.clone()
|
||||
}
|
||||
|
||||
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
|
||||
/// frame's world onto `generated`. Returns false when there is no made frame (the
|
||||
/// first after a reset): `generated` then holds this frame's world too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
post: &super::world_post::Post,
|
||||
screen: &wgpu::TextureView,
|
||||
generated: &wgpu::TextureView,
|
||||
camera: &Camera,
|
||||
) -> bool {
|
||||
let gpu = self.gpu.as_mut().expect("world() ran this frame");
|
||||
post.finish_with(encoder, screen, &gpu.world_group, None);
|
||||
let reset = gpu.reset || camera.reproject.is_none();
|
||||
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
|
||||
let object_motion = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.expect("track_entities() ran this frame")
|
||||
.draw(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&camera.depth,
|
||||
camera.render_size,
|
||||
camera.jittered,
|
||||
camera.view_proj,
|
||||
reproject * camera.view_proj,
|
||||
);
|
||||
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
|
||||
gpu.interpolation.dispatch(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&interpolation::Inputs {
|
||||
color: &gpu.world,
|
||||
depth: &camera.depth,
|
||||
render_size: camera.render_size,
|
||||
jitter: camera.jitter,
|
||||
reproject,
|
||||
object_motion: &object_motion,
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
fov_y: camera.fov_y,
|
||||
reset,
|
||||
flow_vectors: gpu.flow.vectors(),
|
||||
scene_change: gpu.flow.scene_change(),
|
||||
},
|
||||
);
|
||||
gpu.reset = false;
|
||||
if debug() == Some("motion") {
|
||||
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
|
||||
return true;
|
||||
}
|
||||
if reset {
|
||||
post.finish_with(encoder, generated, &gpu.world_group, None);
|
||||
} else {
|
||||
post.finish_with(encoder, generated, &gpu.output_group, None);
|
||||
}
|
||||
!reset
|
||||
}
|
||||
|
||||
/// Hand this frame's pair to the present thread, after the frame's submission: the
|
||||
/// made frame (when `made`) goes first.
|
||||
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last {
|
||||
// A real frame's time, smoothed: the made frame shows for half of it.
|
||||
let dt = (now - last).min(Duration::from_millis(250));
|
||||
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
|
||||
}
|
||||
self.last = Some(now);
|
||||
self.real += 1;
|
||||
self.made += u32::from(made && !generated_only());
|
||||
if now - self.shown_since >= Duration::from_millis(500) {
|
||||
self.made_share = Some(self.made as f32 / self.real as f32);
|
||||
(self.real, self.made, self.shown_since) = (0, 0, now);
|
||||
}
|
||||
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let pair = if generated_only() {
|
||||
Pair {
|
||||
first: None,
|
||||
second: generated,
|
||||
spare: Some(real),
|
||||
interval: self.interval,
|
||||
}
|
||||
} else if made {
|
||||
Pair {
|
||||
first: Some(generated),
|
||||
second: real,
|
||||
spare: None,
|
||||
interval: self.interval,
|
||||
}
|
||||
} else {
|
||||
Pair {
|
||||
first: None,
|
||||
second: real,
|
||||
spare: Some(generated),
|
||||
interval: self.interval,
|
||||
}
|
||||
};
|
||||
let _ = send.send(pair);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame to draw into, like the swapchain's.
|
||||
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation frame"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &config.view_formats,
|
||||
})
|
||||
}
|
||||
|
||||
/// One real frame's frames for the present thread: `first` (the made frame) now,
|
||||
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
|
||||
struct Pair {
|
||||
first: Option<wgpu::Texture>,
|
||||
second: wgpu::Texture,
|
||||
spare: Option<wgpu::Texture>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
/// The thread that copies frames to the swapchain and presents them on time.
|
||||
struct Presenter {
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
send: Option<mpsc::SyncSender<Pair>>,
|
||||
free: mpsc::Receiver<wgpu::Texture>,
|
||||
/// The surface gave something other than a frame: the renderer takes it back.
|
||||
failed: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
fn start(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Arc<wgpu::Surface<'static>>,
|
||||
window: Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Self {
|
||||
// One pair waiting at most: the renderer then waits as for a swapchain image.
|
||||
let (send, receive) = mpsc::sync_channel::<Pair>(1);
|
||||
let (give, free) = mpsc::channel();
|
||||
for _ in 0..FRAMES {
|
||||
let _ = give.send(frame(device, config));
|
||||
}
|
||||
let (device, queue) = (device.clone(), queue.clone());
|
||||
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_here = failed.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("Frame generation present".into())
|
||||
.spawn(move || {
|
||||
let show = |texture: &wgpu::Texture| {
|
||||
if failed_here.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Anything but a frame (outdated, lost, occluded...): the renderer
|
||||
// stops this thread and acquires itself, handling it as it does
|
||||
// without frame generation. A suboptimal frame still shows.
|
||||
let target = match surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(t) => t,
|
||||
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
t
|
||||
}
|
||||
_ => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
target.texture.as_image_copy(),
|
||||
target.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
if vsync {
|
||||
window.pre_present_notify();
|
||||
}
|
||||
queue.present(target);
|
||||
};
|
||||
while let Ok(pair) = receive.recv() {
|
||||
if let Some(first) = pair.first {
|
||||
let shown = Instant::now();
|
||||
show(&first);
|
||||
let _ = give.send(first);
|
||||
// ponytail: sleep pacing; a spin for the last half millisecond
|
||||
// if it shows uneven on some systems.
|
||||
let due = shown + pair.interval / 2;
|
||||
if let Some(wait) = due.checked_duration_since(Instant::now()) {
|
||||
thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
show(&pair.second);
|
||||
let _ = give.send(pair.second);
|
||||
if let Some(spare) = pair.spare {
|
||||
let _ = give.send(spare);
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn the frame generation present thread");
|
||||
Self {
|
||||
config: config.clone(),
|
||||
send: Some(send),
|
||||
free,
|
||||
failed,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface failed it or the thread died (a panic).
|
||||
fn failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
self.send = None;
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::Vec3;
|
||||
|
||||
fn vertex(pos: Vec3) -> Vertex {
|
||||
Vertex {
|
||||
pos: pos.into(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
|
||||
/// another item shows as it is.
|
||||
#[test]
|
||||
fn the_made_frame_s_hand_is_halfway_on_screen() {
|
||||
let mut fg = FrameGen::new();
|
||||
let mut out = Vec::new();
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
|
||||
// The camera turned: the vertex turned with it, the same on screen.
|
||||
let turned = Mat4::from_rotation_y(0.3);
|
||||
let on_screen = Vec3::new(0.4, 0.0, -1.0);
|
||||
let pos = turned.inverse().transform_point3(on_screen);
|
||||
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
|
||||
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
|
||||
let mut other = vertex(Vec3::ZERO);
|
||||
other.uv = [0.5, 0.5];
|
||||
fg.halfway_hand(&[other], turned, &mut out);
|
||||
assert_eq!(out[3].pos, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
|
||||
//! frame can't see; blend the two frames along both fields; fill what is left from a
|
||||
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
|
||||
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
|
||||
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
|
||||
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
|
||||
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
|
||||
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
|
||||
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
|
||||
|
||||
@@ -33,9 +33,6 @@ pub struct Inputs<'a> {
|
||||
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
|
||||
/// the depth.
|
||||
pub reproject: Mat4,
|
||||
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
|
||||
/// (FSR's convention, in UV) where z is 1, used over the camera's.
|
||||
pub object_motion: &'a wgpu::TextureView,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
/// Vertical field of view, radians.
|
||||
@@ -445,7 +442,6 @@ impl Interpolation {
|
||||
&[
|
||||
(0, params()),
|
||||
(2, view(inputs.depth)),
|
||||
(34, view(inputs.object_motion)),
|
||||
(5, view(&r.dilated_depth)),
|
||||
(6, view(&r.dilated_mv)),
|
||||
(9, whole(&r.rec_prev_depth)),
|
||||
@@ -648,12 +644,10 @@ mod tests {
|
||||
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
|
||||
/// a camera that moves by `reproject` each frame, and returns the mean error of the
|
||||
/// last made frame's interior against `expected`, and the last two real frames'.
|
||||
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
|
||||
fn interpolate(
|
||||
seed: u32,
|
||||
shifts: &[[i32; 2]],
|
||||
reproject: Mat4,
|
||||
object: Option<[f32; 2]>,
|
||||
expected: [i32; 2],
|
||||
) -> Option<[f32; 3]> {
|
||||
let (device, queue) = of::device()?;
|
||||
@@ -688,35 +682,6 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
queue.submit([encoder.finish()]);
|
||||
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::super::motion::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
if let Some(mv) = object {
|
||||
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
|
||||
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
|
||||
queue.write_texture(
|
||||
object_motion.as_image_copy(),
|
||||
bytemuck::cast_slice(&texels),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 8),
|
||||
rows_per_image: None,
|
||||
},
|
||||
object_motion.size(),
|
||||
);
|
||||
}
|
||||
let object_motion = object_motion.create_view(&Default::default());
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
let mut interpolation = Interpolation::new(&device, size);
|
||||
let frames: Vec<_> = shifts
|
||||
@@ -766,7 +731,6 @@ mod tests {
|
||||
render_size: size,
|
||||
jitter: [0.0; 2],
|
||||
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
|
||||
object_motion: &object_motion,
|
||||
near: 0.05,
|
||||
far: 512.0,
|
||||
fov_y: 1.2,
|
||||
@@ -812,8 +776,7 @@ mod tests {
|
||||
fn moves_what_the_optical_flow_follows_halfway() {
|
||||
let mut shifts = vec![[0, 0]; 20];
|
||||
shifts.push([16, 8]);
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
|
||||
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -832,26 +795,7 @@ mod tests {
|
||||
// What is at a pixel now was 16 pixels to its right last frame.
|
||||
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
|
||||
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
|
||||
else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
made * 4.0 < previous.min(current),
|
||||
"{made} against {previous} and {current}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Something moving on its own with its own motion vectors (the camera still): the
|
||||
/// made frame is halfway, as with the camera's.
|
||||
#[test]
|
||||
fn moves_what_has_its_own_motion_vectors_halfway() {
|
||||
let size = [512.0, 288.0];
|
||||
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
|
||||
let mv = [-16.0 / size[0], -8.0 / size[1]];
|
||||
let Some([made, previous, current]) =
|
||||
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
|
||||
else {
|
||||
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -24,8 +24,7 @@
|
||||
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
|
||||
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
|
||||
// lists why):
|
||||
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
|
||||
// or an entity's own where `object_motion` has one;
|
||||
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
|
||||
// - the vector fields and reconstructed depths are storage buffers of atomics;
|
||||
// - both inpainting pyramids are a pass per level, and textures of their own;
|
||||
// - the interpolation writes one texture and the inpainting another (rgba16float can't
|
||||
@@ -86,8 +85,6 @@ struct Params {
|
||||
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(32) var interpolated: texture_2d<f32>;
|
||||
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
|
||||
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
|
||||
@group(0) @binding(34) var object_motion: texture_2d<f32>;
|
||||
|
||||
const EPSILON: f32 = 1e-03;
|
||||
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
|
||||
@@ -358,11 +355,7 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let object = textureLoad(object_motion, nearest_coord, 0);
|
||||
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
if object.z > 0.5 {
|
||||
mv = object.xy;
|
||||
}
|
||||
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
|
||||
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
|
||||
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
|
||||
// ReconstructPrevDepth
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
|
||||
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
|
||||
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
|
||||
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
|
||||
//! own motion, limbs and spin included, drawn into a texture at the render size
|
||||
//! (`motion.wgsl`) that the interpolation reads before the camera's.
|
||||
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `motion.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
jittered: [[f32; 4]; 4],
|
||||
current: [[f32; 4]; 4],
|
||||
previous: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct MotionVertex {
|
||||
position: [f32; 3],
|
||||
previous: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
/// 1 for a translucent item, whose texture says where it shows.
|
||||
translucent: u32,
|
||||
}
|
||||
|
||||
pub struct ObjectMotion {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
atlas: wgpu::TextureView,
|
||||
sampler: wgpu::Sampler,
|
||||
params: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// Triangles of the entities matched this frame.
|
||||
staged: Vec<MotionVertex>,
|
||||
/// Last frame's vertices by entity, and its camera.
|
||||
tracked: HashMap<u64, Vec<Vertex>>,
|
||||
camera: DVec3,
|
||||
target: Option<([u32; 2], wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
impl ObjectMotion {
|
||||
/// `atlas`: the entities' texture atlas.
|
||||
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<MotionVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_motion"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
atlas,
|
||||
sampler: device.create_sampler(&Default::default()),
|
||||
params: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
vertices: vertex_buffer(device, 1 << 12),
|
||||
staged: Vec::new(),
|
||||
tracked: HashMap::new(),
|
||||
camera: DVec3::ZERO,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget last frame's entities: the next frame has no motion of its own.
|
||||
pub fn reset(&mut self) {
|
||||
self.tracked.clear();
|
||||
}
|
||||
|
||||
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
|
||||
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
|
||||
/// keep them for the next frame.
|
||||
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
|
||||
self.staged.clear();
|
||||
let shift = (self.camera - camera).as_vec3();
|
||||
let mut tracked = HashMap::with_capacity(self.tracked.len());
|
||||
for (i, &(start, id)) in spans.iter().enumerate() {
|
||||
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
|
||||
let Some(now) = entities
|
||||
.get(start as usize..end)
|
||||
.filter(|v| id != 0 && !v.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(then) = self.tracked.remove(&id)
|
||||
&& then.len() == now.len()
|
||||
&& then
|
||||
.iter()
|
||||
.zip(now)
|
||||
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
|
||||
{
|
||||
let pair = |i: usize| MotionVertex {
|
||||
position: now[i].pos,
|
||||
previous: (Vec3::from(then[i].pos) + shift).into(),
|
||||
uv: now[i].uv,
|
||||
translucent: u32::from(matches!(
|
||||
now[i].layer,
|
||||
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
|
||||
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)),
|
||||
};
|
||||
for q in (0..now.len() / 4).map(|q| q * 4) {
|
||||
self.staged
|
||||
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
|
||||
}
|
||||
}
|
||||
tracked.insert(id, now.to_vec());
|
||||
}
|
||||
self.tracked = tracked;
|
||||
self.camera = camera;
|
||||
}
|
||||
|
||||
/// Draw the matched entities' motion vectors at `size` (the world's render size),
|
||||
/// where `depth` (the world's) shows them; the rest of the texture is 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
jittered: Mat4,
|
||||
current: Mat4,
|
||||
previous: Mat4,
|
||||
) -> wgpu::TextureView {
|
||||
if self.target.as_ref().is_none_or(|t| t.0 != size) {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.target = Some((size, texture.create_view(&Default::default())));
|
||||
}
|
||||
let target = self.target.as_ref().expect("made above").1.clone();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
|
||||
if bytes.len() as u64 > self.vertices.size() {
|
||||
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
queue.write_buffer(&self.vertices, 0, bytes);
|
||||
}
|
||||
let params = Params {
|
||||
jittered: jittered.to_cols_array_2d(),
|
||||
current: current.to_cols_array_2d(),
|
||||
previous: previous.to_cols_array_2d(),
|
||||
};
|
||||
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(¶ms));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
layout: &self.pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&self.atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Frame generation motion"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
if !self.staged.is_empty() {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
|
||||
pass.draw(0..self.staged.len() as u32, 0..1);
|
||||
}
|
||||
drop(pass);
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Frame generation motion vertices"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
|
||||
(0..4)
|
||||
.map(|i| Vertex {
|
||||
pos: [x + i as f32, 0.0, -5.0],
|
||||
uv: [uv, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
|
||||
/// space; a new one, one whose shape changed and untagged geometry get none.
|
||||
#[test]
|
||||
fn tracks_each_entity_by_id() {
|
||||
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
|
||||
return;
|
||||
};
|
||||
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
|
||||
let mut motion = ObjectMotion::new(&device, atlas);
|
||||
let mut entities = quad(0.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.5));
|
||||
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
|
||||
assert!(motion.staged.is_empty());
|
||||
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
|
||||
// another item; 9 is new; then untagged geometry.
|
||||
let mut entities = quad(-1.0, 0.0);
|
||||
entities.extend(quad(10.0, 0.25));
|
||||
entities.extend(quad(20.0, 0.0));
|
||||
entities.extend(quad(30.0, 0.0));
|
||||
motion.track(
|
||||
&entities,
|
||||
&[(0, 7), (4, 8), (8, 9), (12, 0)],
|
||||
DVec3::new(2.0, 0.0, 0.0),
|
||||
);
|
||||
assert_eq!(motion.staged.len(), 6);
|
||||
let v = motion.staged[0];
|
||||
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
|
||||
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
|
||||
assert_eq!(motion.tracked.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
|
||||
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
|
||||
// the world shows it at keep its vector: where the depth is its own, or, for translucent
|
||||
// items (which don't write depth), where its texture shows and nothing is in front.
|
||||
|
||||
struct Params {
|
||||
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
|
||||
// and last frame's projection from this frame's camera.
|
||||
jittered: mat4x4<f32>,
|
||||
current: mat4x4<f32>,
|
||||
previous: mat4x4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var depth: texture_depth_2d;
|
||||
@group(0) @binding(2) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(3) var atlas_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) current: vec4<f32>,
|
||||
@location(1) previous: vec4<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// 1: a translucent item.
|
||||
@location(3) @interpolate(flat) translucent: u32,
|
||||
};
|
||||
|
||||
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
|
||||
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
|
||||
var out: Out;
|
||||
out.uv = uv;
|
||||
out.translucent = translucent;
|
||||
out.clip = params.jittered * vec4(position, 1.0);
|
||||
out.current = params.current * vec4(position, 1.0);
|
||||
out.previous = params.previous * vec4(previous, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
|
||||
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
|
||||
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
|
||||
let tolerance = stored * 1e-3 + 1e-7;
|
||||
// Not what the world shows here: a transparent texel, or behind something.
|
||||
if in.translucent != 0u {
|
||||
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha < 0.1 || in.clip.z < stored - tolerance {
|
||||
discard;
|
||||
}
|
||||
} else if abs(in.clip.z - stored) > tolerance {
|
||||
discard;
|
||||
}
|
||||
if in.previous.w <= 0.0 {
|
||||
discard;
|
||||
}
|
||||
let now = in.current.xy / in.current.w;
|
||||
let then = in.previous.xy / in.previous.w;
|
||||
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
|
||||
}
|
||||
@@ -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(§ions) 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
@@ -374,11 +366,7 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -399,7 +387,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR UI mask")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -410,11 +397,7 @@ impl Hdr {
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
@@ -430,7 +413,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR hand mark")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -445,26 +427,13 @@ 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,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::Texture,
|
||||
world: bool,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -474,7 +443,7 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -525,15 +494,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// 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 +512,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
|
||||
}
|
||||
|
||||
@@ -562,7 +528,6 @@ impl Hdr {
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -576,7 +541,6 @@ impl Hdr {
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -627,12 +591,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 +643,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", ©right),
|
||||
(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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Far meshes share the terrain arenas and passes, with their actual bounds
|
||||
//! Far meshes share the terrain arena and passes, with their actual bounds
|
||||
//! rather than the 16³ boxes used by the near-section residency walk.
|
||||
use super::{Frustum, Renderer, SectionGpu};
|
||||
use crate::{
|
||||
@@ -152,10 +152,6 @@ impl Tile {
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
}
|
||||
/// Both meshes it holds in the arena: its own and the stand-in, if any.
|
||||
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
|
||||
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
key: TileKey,
|
||||
@@ -276,10 +272,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
|
||||
|
||||
@@ -18,14 +18,6 @@ const PREFIX: &str = "horizon_terrain_";
|
||||
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
|
||||
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
|
||||
const ENTRIES: &str = "
|
||||
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
|
||||
override horizon_material_fog: bool = true;
|
||||
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
if horizon_material_fog {
|
||||
return horizon_terrain_apply_fog(color, spherical, cylindrical);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
struct HorizonMaterialOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -39,12 +31,10 @@ 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 {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
let pos = position + origin;
|
||||
var out: HorizonMaterialOut;
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
@@ -69,9 +59,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;
|
||||
}
|
||||
@@ -91,108 +79,26 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
|
||||
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
|
||||
fn horizon_block_pbr_present() -> bool {
|
||||
return textureDimensions(horizon_pbr_normals).x > 1u;
|
||||
}
|
||||
fn horizon_block_pbr_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
|
||||
}
|
||||
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
|
||||
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
|
||||
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
|
||||
}
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
// The maps may be smaller than the colour: the same footprint is fewer of their texels.
|
||||
let scale = f32(textureDimensions(horizon_terrain_sprites).x)
|
||||
/ f32(textureDimensions(horizon_pbr_normals).x);
|
||||
let l = max(lod - log2(scale), 0.0);
|
||||
if map == 0u {
|
||||
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), l);
|
||||
}
|
||||
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), l);
|
||||
}
|
||||
// The prelude's `block_texture`: a sprite of the terrain's arrays at a mip level.
|
||||
fn horizon_block_size() -> vec2<f32> {
|
||||
return vec2<f32>(textureDimensions(horizon_terrain_sprites).xy);
|
||||
}
|
||||
fn horizon_block_sample(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
let s = horizon_terrain_sprite_sampler;
|
||||
let l = i32(layer % horizon_terrain_SPRITE_SPLIT);
|
||||
switch layer / horizon_terrain_SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleLevel(horizon_terrain_sprites, s, uv, l, lod); }
|
||||
case 1u: { return textureSampleLevel(horizon_terrain_sprites_1, s, uv, l, lod); }
|
||||
case 2u: { return textureSampleLevel(horizon_terrain_sprites_2, s, uv, l, lod); }
|
||||
case 3u: { return textureSampleLevel(horizon_terrain_sprites_3, s, uv, l, lod); }
|
||||
case 4u: { return textureSampleLevel(horizon_terrain_sprites_4, s, uv, l, lod); }
|
||||
case 5u: { return textureSampleLevel(horizon_terrain_sprites_5, s, uv, l, lod); }
|
||||
case 6u: { return textureSampleLevel(horizon_terrain_sprites_6, s, uv, l, lod); }
|
||||
default: { return textureSampleLevel(horizon_terrain_sprites_7, s, uv, l, lod); }
|
||||
}
|
||||
}
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
horizon_block_layer = in.layer & 0x7fffu;
|
||||
// The mip level of this point in the texture: its texels, as the sampler would pick.
|
||||
let texels = in.uv * horizon_block_size();
|
||||
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
|
||||
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
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;
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// 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 {
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
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.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
";
|
||||
|
||||
@@ -209,16 +115,6 @@ pub struct TerrainShape<'a> {
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
/// The pack's material id of each sprite layer with one, as `(layer, id)`
|
||||
/// (`Manifest::sprite_materials`).
|
||||
pub sprite_materials: &'a [(u32, u32)],
|
||||
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
|
||||
pub entities: &'a wgpu::BindGroupLayout,
|
||||
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
|
||||
/// stage.
|
||||
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
|
||||
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
|
||||
pub pbr: Option<&'a [wgpu::Texture; 2]>,
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -227,8 +123,6 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
|
||||
@@ -240,14 +134,12 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
for group in table.chunk_by(|a, b| a.1 == b.1) {
|
||||
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
|
||||
fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
|
||||
let mut tags = tags.to_vec();
|
||||
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
|
||||
for group in tags.chunk_by(|a, b| a.1 == b.1) {
|
||||
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
|
||||
out += &format!(
|
||||
" case {}: {{ return {}u; }}\n",
|
||||
@@ -258,60 +150,18 @@ pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// Where a corner of terrain is drawn without a vertex stage.
|
||||
pub(super) const DISPLACE_VANILLA: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
return pos;
|
||||
}
|
||||
";
|
||||
|
||||
/// The vertex stage's call, with its inputs from terrain's vertex.
|
||||
pub(super) const DISPLACE_PACK: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
let flags = u32(round(color.a * 255.0));
|
||||
let high = flags >> 4u;
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`, and the vertex stage in its module when `vertex`.
|
||||
fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> String {
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
DISPLACE_VANILLA
|
||||
},
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
sprite_tags(tags)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex);
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -324,18 +174,14 @@ impl NativeMaterial {
|
||||
frame: &NativeFrame,
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
vertex: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
"horizon_terrain_SPRITE_SPLIT",
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&stage.label),
|
||||
@@ -374,18 +220,6 @@ impl NativeMaterial {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
let solid_targets = [
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(layout),
|
||||
@@ -411,11 +245,11 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -460,7 +294,6 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -513,20 +346,14 @@ mod tests {
|
||||
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
|
||||
let stage = stage(own);
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false).unwrap();
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
|
||||
)
|
||||
});
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -5,7 +5,6 @@ use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::native_material::{NativeMaterial, TerrainShape};
|
||||
use super::native_post::{NativeFrame, NativePost};
|
||||
use super::native_shadow::NativeShadow;
|
||||
use super::native_sky::NativeSky;
|
||||
use super::pack_timing::PassTiming;
|
||||
|
||||
@@ -14,15 +13,6 @@ pub struct NativePack {
|
||||
pub sky: Option<NativeSky>,
|
||||
pub material: Option<NativeMaterial>,
|
||||
pub post: Option<NativePost>,
|
||||
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
|
||||
pub vanilla_clouds: bool,
|
||||
pub vanilla_sun: bool,
|
||||
pub vanilla_stars: bool,
|
||||
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>,
|
||||
}
|
||||
@@ -34,34 +24,12 @@ impl NativePack {
|
||||
/// refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
terrain: &TerrainShape<'_>,
|
||||
report: Report,
|
||||
constants: &[(String, f64)],
|
||||
lights: super::block_light::LightTable,
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
NativeShadow::new(
|
||||
device,
|
||||
terrain,
|
||||
&frame,
|
||||
&s.solid_materials,
|
||||
vertex,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let mut frame = NativeFrame::new(device);
|
||||
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
|
||||
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
|
||||
frame.reflects = report
|
||||
@@ -75,35 +43,6 @@ impl NativePack {
|
||||
.find(|(k, _)| k == id)
|
||||
.is_none_or(|(_, v)| *v != 0.0)
|
||||
});
|
||||
let fog = report.material_fog;
|
||||
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
|
||||
report.sky.as_ref().and_then(key).unwrap_or(true)
|
||||
};
|
||||
let vanilla_clouds = keeps(|s| s.clouds);
|
||||
let vanilla_sun = keeps(|s| s.sun);
|
||||
let vanilla_stars = keeps(|s| s.stars);
|
||||
let weather = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|stage| {
|
||||
super::weather::native_pipeline(
|
||||
device,
|
||||
format,
|
||||
terrain.weather_groups[0],
|
||||
terrain.weather_groups[1],
|
||||
&frame,
|
||||
stage,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let weather_label = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -116,12 +55,10 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| {
|
||||
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
|
||||
})
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
.then(|| NativePost::new(device, post, constants, format, &frame))
|
||||
.transpose()?;
|
||||
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
|
||||
labels.extend(
|
||||
@@ -130,21 +67,14 @@ impl NativePack {
|
||||
.filter(|s| material.is_some() && s.kind == StageKind::Material)
|
||||
.map(|s| s.label.clone()),
|
||||
);
|
||||
labels.extend(weather_label);
|
||||
if let Some(post) = &post {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
post,
|
||||
vanilla_clouds,
|
||||
vanilla_sun,
|
||||
vanilla_stars,
|
||||
weather,
|
||||
shadow,
|
||||
timing: None,
|
||||
};
|
||||
Ok((pack, labels))
|
||||
@@ -152,9 +82,6 @@ impl NativePack {
|
||||
|
||||
/// Whether the pack draws anything the game runs today.
|
||||
pub fn draws(&self) -> bool {
|
||||
self.sky.is_some()
|
||||
|| self.material.is_some()
|
||||
|| self.weather.is_some()
|
||||
|| self.post.is_some()
|
||||
self.sky.is_some() || self.material.is_some() || self.post.is_some()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,44 +6,11 @@
|
||||
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)
|
||||
}
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The game was built with native mods (the game sets it at start): one of them may draw in
|
||||
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
|
||||
pub fn set_mods_built_in(on: bool) {
|
||||
MODS_BUILT_IN.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// 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 +46,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 +57,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 +72,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);
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
|
||||
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
|
||||
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
|
||||
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
|
||||
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
const PREFIX: &str = "horizon_terrain_";
|
||||
|
||||
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
|
||||
/// cutout test (leaves' and plants' holes let the light through).
|
||||
const ENTRIES: &str = "
|
||||
struct HorizonShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) tint: vec3<f32>,
|
||||
@location(3) @interpolate(flat) fluid: u32,
|
||||
}
|
||||
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
|
||||
// (mesher.rs's `fluid_face`).
|
||||
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
|
||||
-> HorizonShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
var out: HorizonShadowOut;
|
||||
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
out.tint = color.rgb;
|
||||
let high = u32(round(color.a * 255.0)) >> 4u;
|
||||
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
|
||||
return out;
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
|
||||
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
|
||||
}
|
||||
// Translucent terrain: its colour where it is the nearest thing the light passes through;
|
||||
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
|
||||
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
if color.a < 0.01 { discard; }
|
||||
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
|
||||
}
|
||||
";
|
||||
|
||||
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
|
||||
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
|
||||
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
|
||||
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
|
||||
const ENTITY_ENTRIES: &str = "
|
||||
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
|
||||
struct HorizonEntityShadowOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) alpha: f32,
|
||||
}
|
||||
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
|
||||
-> HorizonEntityShadowOut {
|
||||
let clip = frame.shadow_view_proj * vec4(position, 1.0);
|
||||
let xy = shadow_distort(clip.xy);
|
||||
let overlay = layer & 0xffffu;
|
||||
// As entity.wgsl's `unlit`.
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|
||||
|| (overlay >= 9u && overlay <= 12u);
|
||||
var out: HorizonEntityShadowOut;
|
||||
// Unlit geometry lands outside the clip volume.
|
||||
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
|
||||
out.uv = uv;
|
||||
out.alpha = color.a;
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
|
||||
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
|
||||
if alpha * in.alpha < 0.5 { discard; }
|
||||
}
|
||||
";
|
||||
|
||||
/// `shadow_color`'s format.
|
||||
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
|
||||
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
|
||||
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
|
||||
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
|
||||
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
|
||||
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
|
||||
/// shadow, and ends past the build height and the slant across the map below the camera.
|
||||
pub fn camera(settings: &Settings) -> Shadow {
|
||||
Shadow {
|
||||
resolution: settings.resolution,
|
||||
distance: settings.distance,
|
||||
render_mul: 1.0,
|
||||
interval: SNAP,
|
||||
euclidean_snap: true,
|
||||
near: 256.0,
|
||||
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
|
||||
..Shadow::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `settings` with the resolution `device` can make.
|
||||
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
|
||||
Settings {
|
||||
resolution: settings
|
||||
.resolution
|
||||
.min(device.limits().max_texture_dimension_2d),
|
||||
..settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
|
||||
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
|
||||
/// three times as fine as at its edge.
|
||||
const SNAP: f32 = 8.0;
|
||||
|
||||
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
|
||||
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
|
||||
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
|
||||
|
||||
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
|
||||
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
|
||||
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
|
||||
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
|
||||
pub fn distortion(distance: f32, resolution: u32) -> f32 {
|
||||
let texel = 2.0 * distance / resolution.max(1) as f32;
|
||||
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
|
||||
}
|
||||
|
||||
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
|
||||
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
|
||||
/// in, and that terrain's colour.
|
||||
pub struct ShadowMap {
|
||||
depth: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
translucent: wgpu::Texture,
|
||||
pub translucent_view: wgpu::TextureView,
|
||||
pub color_view: wgpu::TextureView,
|
||||
pub sampler: wgpu::Sampler,
|
||||
/// The 1×1 map still holds the zeroes it was made with.
|
||||
blank_uncleared: bool,
|
||||
}
|
||||
|
||||
impl ShadowMap {
|
||||
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
|
||||
let size = settings.map_or(1, |s| s.resolution);
|
||||
let texture = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let depth = texture(
|
||||
"Native shader pack shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let translucent = texture(
|
||||
"Native shader pack translucent shadow map",
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let color = texture(
|
||||
"Native shader pack shadow colour",
|
||||
SHADOW_COLOR,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Native shader pack shadow comparison"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Self {
|
||||
view: depth.create_view(&Default::default()),
|
||||
depth,
|
||||
translucent_view: translucent.create_view(&Default::default()),
|
||||
translucent,
|
||||
color_view: color.create_view(&Default::default()),
|
||||
sampler,
|
||||
blank_uncleared: settings.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A depth pass into the map that clears it to "far".
|
||||
pub fn begin<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0), timing)
|
||||
}
|
||||
|
||||
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
|
||||
/// copied: where one casts, the solid map is the darker and decides.
|
||||
pub fn begin_entities<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.depth_pass(encoder, label, wgpu::LoadOp::Load, None)
|
||||
}
|
||||
|
||||
fn depth_pass<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
load: wgpu::LoadOp<f32>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
|
||||
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
|
||||
pub fn begin_translucent<'e>(
|
||||
&self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.copy_texture_to_texture(
|
||||
self.depth.as_image_copy(),
|
||||
self.translucent.as_image_copy(),
|
||||
self.depth.size(),
|
||||
);
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.color_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.translucent_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Clears the 1×1 map of a pack without `[shadow]` once.
|
||||
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if std::mem::take(&mut self.blank_uncleared) {
|
||||
drop(self.begin(encoder, "Native shader pack shadow map clear", None));
|
||||
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear", None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
|
||||
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
|
||||
/// stages' own group 3 also holds the map being drawn).
|
||||
pub struct NativeShadow {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub flat: Option<wgpu::RenderPipeline>,
|
||||
/// The translucent terrain's draws into the translucent map and the colour, instanced and
|
||||
/// flat.
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat_translucent: Option<wgpu::RenderPipeline>,
|
||||
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
|
||||
pub entity: wgpu::RenderPipeline,
|
||||
pub group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The entity draw's shader.
|
||||
fn entity_source() -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
|
||||
fn entity_pipeline(
|
||||
device: &wgpu::Device,
|
||||
entities: &wgpu::BindGroupLayout,
|
||||
frame_layout: &wgpu::BindGroupLayout,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
|
||||
});
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
|
||||
groups[0] = Some(entities);
|
||||
groups[GROUP as usize] = Some(frame_layout);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack entity shadow"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("horizon_entity_shadow_fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
|
||||
/// (`(layer, id)`), is one of the pack's `solid` ones.
|
||||
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
let layers: Vec<String> = materials
|
||||
.iter()
|
||||
.filter(|(_, id)| solid.contains(id))
|
||||
.map(|(layer, _)| format!("{layer}u"))
|
||||
.collect();
|
||||
let cases = if layers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" case {}: {{ return true; }}\n", layers.join(", "))
|
||||
};
|
||||
format!(
|
||||
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
|
||||
default: {{ return false; }}\n }}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
|
||||
let (head, displace) = match vertex {
|
||||
Some(stage) => (
|
||||
stage.to_owned()
|
||||
+ &super::native_material::layer_table("horizon_sprite_material", materials),
|
||||
super::native_material::DISPLACE_PACK,
|
||||
),
|
||||
None => (
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
super::native_material::DISPLACE_VANILLA,
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
impl NativeShadow {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
vertex: Option<&horizon_shader_api::check::CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(
|
||||
&shadow_solid(shape.sprite_materials, solid_materials),
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let mut constants = super::native_post::present_constants(&module, constants);
|
||||
constants.extend([
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
]);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack shadow frame"),
|
||||
layout: &frame_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: frame.buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
|
||||
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
|
||||
Default::default();
|
||||
groups[0] = Some(shape.globals);
|
||||
groups[1] = flat;
|
||||
groups[GROUP as usize] = Some(&frame_layout);
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
bind_group_layouts: &groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let color_target = [Some(wgpu::ColorTargetState {
|
||||
format: SHADOW_COLOR,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})];
|
||||
let pipeline = |layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
translucent: bool| {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: options.clone(),
|
||||
buffers,
|
||||
},
|
||||
// Both sides: the quads that face the light are drawn (`facing_runs`), and
|
||||
// plants' crossed quads cast from either side.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 1.5,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(if translucent {
|
||||
"horizon_shadow_fs_translucent"
|
||||
} else {
|
||||
"horizon_shadow_fs"
|
||||
}),
|
||||
compilation_options: options,
|
||||
targets: if translucent { &color_target } else { &[] },
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let instanced = layout(None);
|
||||
let solid = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances.clone())],
|
||||
false,
|
||||
);
|
||||
let translucent = pipeline(
|
||||
&instanced,
|
||||
"horizon_shadow_vs",
|
||||
&[Some(vertices.clone()), Some(instances)],
|
||||
true,
|
||||
);
|
||||
let flat_layout = shape.flat.map(|group| layout(Some(group)));
|
||||
let flat_pipeline = |translucent| {
|
||||
flat_layout.as_ref().map(|layout| {
|
||||
pipeline(
|
||||
layout,
|
||||
"horizon_shadow_vs_flat",
|
||||
&[Some(vertices.clone())],
|
||||
translucent,
|
||||
)
|
||||
})
|
||||
};
|
||||
let flat = flat_pipeline(false);
|
||||
let flat_translucent = flat_pipeline(true);
|
||||
let entity = entity_pipeline(device, shape.entities, &frame_layout);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("shadow pass: {e}"));
|
||||
}
|
||||
Ok(Self {
|
||||
solid,
|
||||
flat,
|
||||
translucent,
|
||||
flat_translucent,
|
||||
entity,
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
|
||||
let materials = [(3, 7), (4, 8), (9, 7)];
|
||||
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
|
||||
assert!(!shadow_solid(&materials, &[]).contains("case"));
|
||||
assert!(!shadow_solid(&materials, &[5]).contains("case"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entity_shader_validates() {
|
||||
let text = entity_source();
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
&shadow,
|
||||
);
|
||||
// Up to the build height's span below a player flying high, as well as above them.
|
||||
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
|
||||
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
|
||||
assert!(clip.is_finite(), "{clip}");
|
||||
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
|
||||
let centre = view.inverse().w_axis.truncate();
|
||||
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
|
||||
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
|
||||
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
|
||||
// Already that fine: no distortion.
|
||||
assert_eq!(distortion(32.0, 2048), 0.0);
|
||||
assert_eq!(distortion(10_000.0, 1024), 0.95);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -252,7 +252,7 @@ mod tests {
|
||||
resource: globals_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(&device);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
|
||||
@@ -620,7 +620,6 @@ impl Oit {
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
@@ -646,7 +645,7 @@ impl Oit {
|
||||
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
|
||||
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
|
||||
if let Some((mask, _)) = water_mask {
|
||||
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
|
||||
let [c0, c1] = &t.views.coefficients;
|
||||
@@ -681,7 +680,6 @@ impl Oit {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
@@ -694,7 +692,6 @@ impl Oit {
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("OIT features")),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
@@ -713,7 +710,7 @@ impl Oit {
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth, timing.as_deref_mut());
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
@@ -723,18 +720,16 @@ impl Oit {
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask, timing.as_deref_mut());
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT composite")),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
@@ -754,7 +749,6 @@ impl Oit {
|
||||
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
|
||||
/// the world's depth (after the cull) and draws into it, so the later
|
||||
/// stages, which only read it, keep each pixel's nearest cloud face.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -770,7 +764,6 @@ impl Oit {
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
@@ -781,7 +774,6 @@ impl Oit {
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT clouds")),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
@@ -806,7 +798,6 @@ impl Oit {
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
@@ -819,7 +810,6 @@ impl Oit {
|
||||
// The mask pipeline writes depth only but has a colour target.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT water mask"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT water mask")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
@@ -850,7 +840,6 @@ impl Oit {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
@@ -863,7 +852,6 @@ impl Oit {
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("OIT cull depth bounds")),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", Some(wgpu::Face::Back), offset),
|
||||
kind("text", "entity", None, offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
@@ -217,12 +217,7 @@ impl Features {
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind(
|
||||
"additive entities",
|
||||
"lightning",
|
||||
Some(wgpu::Face::Back),
|
||||
PLAIN,
|
||||
),
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, Val};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -408,7 +407,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.
|
||||
@@ -436,33 +434,9 @@ pub(super) struct Weather {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Shadow cache: the key of the shadow map the shadow targets hold. Only the
|
||||
/// shadow passes write those targets, so while the key matches they still hold
|
||||
/// it and the pass is skipped.
|
||||
#[derive(Default)]
|
||||
struct ShadowCache {
|
||||
key: Option<ShadowCacheKey>,
|
||||
}
|
||||
|
||||
/// Key for validating the shadow cache.
|
||||
/// Must match the cache state exactly to be considered valid.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ShadowCacheKey {
|
||||
sun_angle: f32,
|
||||
moon_angle: f32,
|
||||
snapped_camera: Option<DVec3>,
|
||||
shadow_settings_hash: u64,
|
||||
shadow_scene_revision: (u64, u64),
|
||||
ticks: u64,
|
||||
day_time: i64,
|
||||
shadow_uniforms_hash: u64,
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
terrain: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
@@ -511,11 +485,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 +1314,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 +1406,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 +1866,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 +1883,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 +2216,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,
|
||||
@@ -2394,7 +2330,6 @@ impl PackPost {
|
||||
u.shadow = shadow_config;
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2779,13 +2714,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 +2832,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 +2848,6 @@ impl PackPost {
|
||||
entities,
|
||||
spans,
|
||||
drawn,
|
||||
(crumbling, self.gui_size),
|
||||
shadow,
|
||||
rest,
|
||||
);
|
||||
@@ -2944,13 +2870,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()
|
||||
@@ -3172,7 +3091,8 @@ impl PackPost {
|
||||
|
||||
/// What the shadow camera draws of the terrain this frame, when the pack draws it.
|
||||
pub fn shadow_view(&self, frame: &super::Frame) -> Option<ShadowView> {
|
||||
if !self.has_shadow() {
|
||||
self.shadow_targets.as_ref()?;
|
||||
if self.shadow_terrain.is_none() && self.shadow_water.is_none() {
|
||||
return None;
|
||||
}
|
||||
let shadow = &self.uniforms.shadow;
|
||||
@@ -3193,12 +3113,6 @@ impl PackPost {
|
||||
self.terrain.is_some()
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
pub fn has_shadow(&self) -> bool {
|
||||
self.shadow_targets.is_some()
|
||||
&& (self.shadow_terrain.is_some() || self.shadow_water.is_some())
|
||||
}
|
||||
|
||||
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
|
||||
/// What the world's background is when the pack draws the sky: colortex0's clear colour
|
||||
/// (`None`: the fog colour, its default).
|
||||
@@ -3408,45 +3322,6 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
frame: &super::Frame,
|
||||
) {
|
||||
// Opt-in only (`horizon-graphics.txt` shadowCache). Off: always redraw, drop any cache.
|
||||
if !frame.shadow_cache {
|
||||
self.shadow_cache = ShadowCache::default();
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the current frame's cache key before borrowing shadow_targets mutably.
|
||||
let cache_key = self.compute_shadow_cache_key(frame);
|
||||
|
||||
// Entity vertices are interpolated each frame, so their shadows cannot be reused.
|
||||
let has_shadow_entities = self
|
||||
.entities
|
||||
.as_ref()
|
||||
.is_some_and(|entities| entities.geometry.draws.iter().any(|draw| draw.shadow));
|
||||
// Custom images are cleared each frame (`pack_images::clear`) before the
|
||||
// shadow pass writes them, so a pack with any redraws it too.
|
||||
let cache_hit = !has_shadow_entities
|
||||
&& self.images.is_empty()
|
||||
&& self.shadow_cache.key == Some(cache_key)
|
||||
&& self.shadow_targets.is_some();
|
||||
if cache_hit {
|
||||
// The targets still hold this map (and `main`, the A/B side
|
||||
// `shadowcomp` left current).
|
||||
return;
|
||||
}
|
||||
self.draw_shadow_pass(device, queue, encoder, terrain);
|
||||
self.shadow_cache.key = self.shadow_targets.is_some().then_some(cache_key);
|
||||
}
|
||||
|
||||
/// The shadow pass body: terrain, entities, clears, and `shadowcomp`.
|
||||
fn draw_shadow_pass(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(t) = &mut self.shadow_targets else {
|
||||
return;
|
||||
@@ -3522,106 +3397,6 @@ impl PackPost {
|
||||
self.shadow_comp = comp;
|
||||
}
|
||||
|
||||
/// Computes the cache key for the current frame.
|
||||
fn compute_shadow_cache_key(&self, frame: &super::Frame) -> ShadowCacheKey {
|
||||
let shadow = &self.uniforms.shadow;
|
||||
// Use the same logic as shadow_camera to compute the light direction and snapped camera.
|
||||
// Use the public shadow_light function which returns the normalized light direction.
|
||||
let light = super::pack_uniforms::shadow_light(frame, shadow);
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(frame.camera);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped_camera = if step > 0.0 {
|
||||
Some((at / step).floor() * step)
|
||||
} else {
|
||||
Some(at)
|
||||
};
|
||||
|
||||
// Hash the shadow settings that affect the shadow map.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
shadow.resolution.hash(&mut hasher);
|
||||
shadow.distance.to_bits().hash(&mut hasher);
|
||||
shadow.render_mul.to_bits().hash(&mut hasher);
|
||||
shadow.interval.to_bits().hash(&mut hasher);
|
||||
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
|
||||
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
|
||||
shadow.near.to_bits().hash(&mut hasher);
|
||||
shadow.far.to_bits().hash(&mut hasher);
|
||||
for hf in &shadow.hardware_filtering {
|
||||
hf.hash(&mut hasher);
|
||||
}
|
||||
for color in &shadow.colors {
|
||||
format!("{:?}", color.format).hash(&mut hasher);
|
||||
color.clear.hash(&mut hasher);
|
||||
if let Some(cc) = color.clear_color {
|
||||
for c in cc {
|
||||
c.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
color.mipmap.hash(&mut hasher);
|
||||
}
|
||||
let shadow_settings_hash = hasher.finish();
|
||||
let shadow_uniforms_hash = self.shadow_uniforms_hash();
|
||||
|
||||
ShadowCacheKey {
|
||||
sun_angle: frame.sky.sun_angle,
|
||||
moon_angle: frame.sky.moon_angle,
|
||||
snapped_camera,
|
||||
shadow_settings_hash,
|
||||
shadow_scene_revision: frame.shadow_scene_revision,
|
||||
ticks: frame.ticks,
|
||||
day_time: frame.pack.day_time,
|
||||
shadow_uniforms_hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow_uniforms_hash(&self) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
let passes = self
|
||||
.shadow_terrain
|
||||
.iter()
|
||||
.chain(self.shadow_water.iter())
|
||||
.chain(self.shadow_comp.iter())
|
||||
.chain(
|
||||
self.entities
|
||||
.iter()
|
||||
.flat_map(|entities| entities.shadow.iter().flatten()),
|
||||
);
|
||||
for pass in passes {
|
||||
pass.name.hash(&mut hasher);
|
||||
for (index, ty, offset) in &pass.uniforms {
|
||||
index.hash(&mut hasher);
|
||||
ty.hash(&mut hasher);
|
||||
offset.hash(&mut hasher);
|
||||
let Some(value) = index.and_then(|index| self.uniforms.values.get(index)) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
pack_uniforms::Value::F(values) => {
|
||||
0u8.hash(&mut hasher);
|
||||
for value in values {
|
||||
value.to_bits().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
pack_uniforms::Value::I(values) => {
|
||||
1u8.hash(&mut hasher);
|
||||
values.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
|
||||
/// the result onto `screen`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -3668,10 +3443,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 {
|
||||
@@ -3693,7 +3464,7 @@ impl PackPost {
|
||||
self.gpu.clear,
|
||||
&mut self.zeros,
|
||||
);
|
||||
self.run_shadow(device, queue, encoder, terrain, frame);
|
||||
self.run_shadow(device, queue, encoder, terrain);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
@@ -3910,10 +3681,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()
|
||||
@@ -3953,36 +3720,6 @@ mod tests {
|
||||
use super::*;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn shadow_cache_key_tracks_world_and_time_changes() {
|
||||
let key = ShadowCacheKey {
|
||||
sun_angle: 0.0,
|
||||
moon_angle: 180.0,
|
||||
snapped_camera: Some(DVec3::ZERO),
|
||||
shadow_settings_hash: 1,
|
||||
shadow_scene_revision: (2, 3),
|
||||
ticks: 4,
|
||||
day_time: 5,
|
||||
shadow_uniforms_hash: 6,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_scene_revision: (8, 3),
|
||||
..key
|
||||
}
|
||||
);
|
||||
assert_ne!(key, ShadowCacheKey { day_time: 9, ..key });
|
||||
assert_ne!(
|
||||
key,
|
||||
ShadowCacheKey {
|
||||
shadow_uniforms_hash: 10,
|
||||
..key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Two textures with a sampler each become two textures sharing one sampler variable.
|
||||
#[test]
|
||||
fn samplers_of_a_kind_share_one_variable() {
|
||||
@@ -4012,7 +3749,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;
|
||||
};
|
||||
|
||||
Loaded 100 of 1027 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user