Compare commits

..
Author SHA1 Message Date
rdbtCVSandGPT 6.1 Sol ad777a0249 Merge main into split/horizon-ctl-crate
Resolve the benchmark documentation conflict by preserving the extracted horizon-ctl build target and main's Python 3.11 prerequisite.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:27:05 +00:00
rdbtCVSandGPT 6.1 Sol 3533ab20f5 Merge main into split/horizon-ctl-crate
Preserve both new lockfile packages and combine the official-game and extracted horizon-ctl selections in the Discord harness.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:31:40 +00:00
Horizon agent 79cfbfc950 Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	tools/test_friends_integration_parity.py
2026-11-09 20:31:47 +01:00
Horizon agent a82091736e Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	.github/workflows/ci.yml
#	.github/workflows/game-build.yml
#	crates/horizon-client-lib/Cargo.toml
#	tools/ci/build-musl.sh
2026-11-09 18:57:38 +01:00
Horizon agentandClaude Opus 5.5 40637018fa Cargo.lock: the horizon-ctl package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:38:53 +01:00
Horizon agentandClaude Opus 5.5 c48cdddd66 horizon-ctl: its own platform crate (layer plan M6)
The horizon-ctl binary and its MCP server leave horizon-client-lib for
crates/horizon-ctl, a platform crate on the client lib's control socket.
horizon-client-lib no longer depends on horizon-mcp. Pure move: the
sources are unchanged; build commands now name -p horizon-ctl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:13:46 +01:00
665 changed files with 6124 additions and 40288 deletions

No files matched your search

+1 -2
View File
@@ -36,9 +36,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
+13 -17
View File
@@ -29,18 +29,16 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's) take this repository's own
# pull requests whose number ends in 0 or 1, about what their three runners can do; GitHub's runners take the others,
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# Where (docs/ci-runners.md): our checks runners (label horizon-checks: the desktop's, an isolated user, so a fork's
# pull request runs there too) take the pull requests whose number ends in 0 or 1, about what their three runners
# can do; GitHub's runners take the others and main's pushes (which also save the Rust cache GitHub's pull request
# runs read), so both pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to
# ours, `light` (the desktop paused) or `off` every job to GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
@@ -112,7 +110,7 @@ jobs:
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
shared-key: verify
@@ -143,8 +141,7 @@ jobs:
launcher:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
github.event_name == 'pull_request' && (endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-24.04' }}
timeout-minutes: 45
@@ -169,7 +166,7 @@ jobs:
libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: steps.launcher.outputs.required != 'false' && runner.environment == 'github-hosted'
with:
workspaces: launcher
@@ -202,14 +199,13 @@ jobs:
run: |
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
shared-key: windows-arm64-game
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
with:
workspaces: launcher
shared-key: windows-arm64-launcher
@@ -225,7 +221,7 @@ jobs:
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
+4 -7
View File
@@ -50,9 +50,7 @@ jobs:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
@@ -158,11 +156,10 @@ jobs:
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted' && !matrix.musl
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
@@ -173,7 +170,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --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-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
+2 -3
View File
@@ -173,11 +173,10 @@ jobs:
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: launcher
+3 -7
View File
@@ -20,12 +20,8 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -34,7 +30,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
+1 -3
View File
@@ -55,10 +55,8 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
- uses: dtolnay/rust-toolchain@stable
if: steps.when.outputs.run == 'true'
with:
toolchain: stable
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
fi
rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
+1 -3
View File
@@ -37,9 +37,7 @@ jobs:
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
+1 -3
View File
@@ -71,9 +71,7 @@ jobs:
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable, 2026-11
with:
toolchain: stable
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
+3 -7
View File
@@ -20,12 +20,8 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's. A fork's
# pull request sees no repository variable, so it always runs on GitHub's (it would wait for the desktop when off).
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
# Our checks runners (label horizon-checks, docs/ci-runners.md); SELF_HOSTED_CI=light or off: GitHub's.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
timeout-minutes: 20
defaults:
run:
@@ -34,7 +30,7 @@ jobs:
- uses: actions/checkout@v7
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
+13 -6
View File
@@ -22,6 +22,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
blocks, `--request-changes` when something does.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
@@ -78,7 +85,8 @@ 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-lib` | Reusable client systems, the control socket, 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-ctl` | `horizon-ctl`, the CLI over the control socket, and its MCP server (platform): `commands.rs` is the command table ([docs/agent-control.md](docs/agent-control.md)) |
| `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`...) |
@@ -128,8 +136,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests; paste its block in the PR
python3 tools/pr_ready.py --harnesses # also the affected harnesses (by default only when you change a harness, oracle, probe or the runner, as in CI)
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 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
@@ -138,8 +146,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
python3 tools/lang_coverage.py --summary # one line per language: how many keys it carries and what it misses
python3 tools/lang_coverage.py --summary --show-keys # the same, then the keys behind the counts
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
@@ -211,7 +218,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Agent control | L: `control.rs` (socket), `crates/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` |
+3 -4
View File
@@ -109,10 +109,9 @@ python3 tools/pr_ready.py --base upstream/main # or origin/main if you work on
```
It looks at what your branch changes and runs what matters: rustfmt, clippy with warnings denied, the tests of the
crates you touched (and the launcher's), generated files, `Cargo.lock` and the Python tests. Like CI, it runs the
vanilla harnesses only when your branch changes a harness, an oracle, a probe or the runner; otherwise it lists the
ones your change can affect, and main's parity run checks them after the merge. `--harnesses` runs them on your
machine anyway (it needs the vanilla data). It exits non-zero when something fails.
crates you touched (and the launcher's), generated files, `Cargo.lock`, the Python tests, and the vanilla harnesses
your change can affect. Without the vanilla data on your machine it lists those harnesses instead; CI runs them.
`--quick` skips the harnesses. It exits non-zero when something fails.
For the game workspace, Clippy checks all targets of the touched crates and their transitive workspace dependents,
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
Generated
+13 -47
View File
@@ -706,25 +706,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.14"
@@ -945,12 +926,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1548,6 +1523,7 @@ dependencies = [
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
@@ -1556,7 +1532,6 @@ dependencies = [
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1578,7 +1553,6 @@ dependencies = [
"open",
"png",
"pollster",
"rayon",
"rustix 1.1.5",
"serde",
"serde_json",
@@ -1609,6 +1583,7 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1674,6 +1649,17 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-ctl"
version = "0.1.0"
dependencies = [
"horizon-client-lib",
"horizon-core",
"horizon-mcp",
"horizon-threads",
"serde_json",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
@@ -3689,26 +3675,6 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "rc2"
version = "0.8.1"
+2
View File
@@ -13,6 +13,8 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
# horizon-ctl and its MCP server (platform).
"crates/horizon-ctl",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
+1 -1
View File
@@ -7,7 +7,7 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The hook the hub asks (`chat_guard`), and the moderation durations.
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+36 -136
View File
@@ -1,8 +1,8 @@
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
//! player's text and carries out what it decides.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -10,8 +10,6 @@ use std::path::{Path, PathBuf};
mod normalise;
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
pub use horizon_server_files::chat_guard::{Action, Mute};
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
@@ -223,59 +221,49 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
}
/// A mute as the mutes file keeps it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct SavedMute {
uuid: String,
name: String,
until: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
/// What a hit leads to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// The text is refused, nothing more.
Refused,
/// Refused, and the player can't chat until then (epoch milliseconds).
Muted { until: i64 },
/// Refused, and the player is banned until then.
Banned { until: i64 },
}
impl From<SavedMute> for Mute {
fn from(m: SavedMute) -> Self {
let SavedMute {
uuid,
name,
until,
by,
reason,
} = m;
Mute {
uuid,
name,
until,
by,
reason,
impl Action {
pub fn name(self) -> &'static str {
match self {
Action::Refused => "refused",
Action::Muted { .. } => "muted",
Action::Banned { .. } => "banned",
}
}
}
impl From<Mute> for SavedMute {
fn from(m: Mute) -> Self {
let Mute {
uuid,
name,
until,
by,
reason,
} = m;
SavedMute {
uuid,
name,
until,
by,
reason,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
struct MutesFile {
mutes: Vec<SavedMute>,
mutes: Vec<Mute>,
}
/// One player's hits: how many, and when the last one stops counting.
@@ -305,7 +293,7 @@ impl Guard {
{
for m in saved.mutes {
if let Some(uuid) = parse_uuid(&m.uuid) {
mutes.insert(uuid, m.into());
mutes.insert(uuid, m);
}
}
}
@@ -443,7 +431,7 @@ impl Guard {
};
let now = now_millis();
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
let tmp = file.with_extension("json.tmp");
@@ -454,94 +442,6 @@ impl Guard {
}
}
impl ChatGuard for Guard {
fn matches(&self, text: &str) -> bool {
self.filter.matches(text)
}
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
Guard::muted(self, uuid, now)
}
fn mutes(&self, now: i64) -> Vec<&Mute> {
Guard::mutes(self, now)
}
fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
Guard::mute(self, uuid, name, until, by, reason);
}
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
Guard::strike(self, uuid, name, now)
}
fn unmute(&mut self, name: &str) -> Option<String> {
Guard::unmute(self, name)
}
fn log(
&mut self,
now: i64,
name: &str,
uuid: u128,
place: Place,
count: u32,
action: Action,
text: &str,
) -> Result<(), String> {
let path = &self.config.log;
if path.is_empty() {
return Ok(());
}
let line = log_line(now, name, uuid, place.name(), count, action, text);
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
}
}
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
let mut loaded = Loaded::default();
let config = match Config::from_server_toml(server_toml) {
Ok(config) => config,
Err(e) => {
loaded.log.push((
"ERROR",
format!("server.toml: {e}; the chat filter stays off"),
));
return loaded;
}
};
if !config.enabled {
return loaded;
}
let (guard, problem) = Guard::load(config, dir);
if let Some(problem) = problem {
loaded.log.push(("WARN", problem));
}
loaded.log.push((
"INFO",
format!(
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
guard.filter.len()
),
));
loaded.guard = Some(Box::new(guard));
loaded
}
/// Gives the dedicated server this chat filter.
pub fn install() {
chat_guard::install(load);
}
/// One line of `logs/chat-filter.log`, without its newline.
pub fn log_line(
now: i64,
@@ -51,14 +51,8 @@ fn float(tag: Option<&Tag>) -> Option<f32> {
other => int(Some(other))? as f32,
})
}
/// `TagValueInput.getBooleanOr`: a number is true when its byte value is not 0.
fn flag(tag: Option<&Tag>) -> Option<bool> {
Some(int(tag)? as i8 != 0)
}
/// `Codec.BOOL` through `NbtOps.getBooleanValue`: a number is true when it
/// is not 0 as a whole (`doubleValue() != 0`), unlike `getBooleanOr`.
fn codec_flag(tag: Option<&Tag>) -> Option<bool> {
Some(float(tag)? != 0.0)
Some(int(tag)? != 0)
}
fn boolean(v: bool) -> Tag {
Tag::Byte(i8::from(v))
@@ -277,31 +271,15 @@ fn int_array(tag: Option<&Tag>) -> Option<&Vec<i32>> {
fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The default `SignText`: four empty lines, black, not glowing.
fn empty_sign_side() -> Tag {
Tag::compound([
("messages", Tag::List(vec![string(""); 4])),
("color", string("black")),
("has_glowing_text", boolean(false)),
])
}
/// The loaded `SignText` of one side, or the empty text.
pub fn sign_side(tag: Option<&Tag>) -> Tag {
// `SignText.DIRECT_CODEC`: `messages` is a component list (an entry that
// fails is dropped, `ListCodec`'s partial result) cut to 4 lines by
// `Util.fixedSize`. Fewer than 4 valid lines fail the side, and
// `SignBlockEntity.loadAdditional` then loads an empty one; more keep the
// first 4 with the side's colour and glow.
let lines: Option<Vec<Tag>> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => {
let lines: Vec<Tag> = list.iter().filter_map(component).take(4).collect();
(lines.len() == 4).then_some(lines)
}
_ => None,
};
let Some(messages) = lines else {
return empty_sign_side();
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
_ => Vec::new(),
};
messages.resize_with(4, empty);
messages.truncate(4);
let mut out = vec![
("messages", Tag::List(messages)),
(
@@ -314,7 +292,7 @@ pub fn sign_side(tag: Option<&Tag>) -> Tag {
(
"has_glowing_text",
boolean(
tag.and_then(|t| codec_flag(t.get("has_glowing_text")))
tag.and_then(|t| flag(t.get("has_glowing_text")))
.unwrap_or(false),
),
),
@@ -874,32 +852,6 @@ mod tests {
);
}
/// `getBooleanOr` tests a number's byte value: an int 256 is false and
/// 257 true, for a sign's wax and a piston's flags alike.
#[test]
fn booleans_are_read_as_bytes() {
let sign = |waxed: &str| {
canon(&format!(
"{{id:\"minecraft:sign\",x:1,y:2,z:3,is_waxed:{waxed}}}"
))
.get("is_waxed")
.cloned()
};
assert_eq!(sign("1b"), Some(Tag::Byte(1)));
assert_eq!(sign("256"), Some(Tag::Byte(0)));
assert_eq!(sign("257"), Some(Tag::Byte(1)));
// `SignText` reads `has_glowing_text` through `Codec.BOOL`, which tests
// the whole number: 256 is true there.
let glowing = canon(
r#"{id:"minecraft:sign",x:1,y:2,z:3,front_text:{messages:["","","",""],has_glowing_text:256}}"#,
);
let front = glowing.get("front_text").unwrap();
assert_eq!(front.get("has_glowing_text"), Some(&Tag::Byte(1)));
let piston = canon(r#"{id:"minecraft:piston",x:1,y:2,z:3,extending:256,source:513}"#);
assert_eq!(piston.get("extending"), Some(&Tag::Byte(0)));
assert_eq!(piston.get("source"), Some(&Tag::Byte(1)));
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -1005,32 +957,6 @@ mod tests {
assert_eq!(structure.get("showboundingbox"), Some(&Tag::Byte(1)));
}
#[test]
fn sign_side_needs_exactly_four_messages() {
let ok = snbt("{messages:[\"a\",\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}");
let side = sign_side(Some(&ok));
assert_eq!(side.get("color"), Some(&string("red")));
// A fifth line, or a bad entry among five, leaves the first 4 valid
// lines with the colour (checked against the 26.3 codec).
for kept in [
"{messages:[\"a\",\"\",\"\",\"\",\"e\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",1,\"\",\"\",\"\"],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(kept)));
assert_eq!(side.get("messages"), ok.get("messages"), "{kept}");
assert_eq!(side.get("color"), Some(&string("red")), "{kept}");
}
for bad in [
"{color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"b\"],color:\"red\",has_glowing_text:1b}",
"{messages:[\"a\",\"\",\"\",1],color:\"red\",has_glowing_text:1b}",
] {
let side = sign_side(Some(&snbt(bad)));
assert_eq!(side, sign_side(None), "{bad}");
assert_eq!(side.get("color"), Some(&string("black")));
}
}
#[test]
fn canonical_is_idempotent() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -356,12 +356,6 @@ impl KeyBinds {
pub fn is_default(&self, name: &str) -> bool {
self.get(name) == Self::default_of(name)
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// mapping differs from its default (`KeyMapping.isDefault`, the bound key
/// against `defaultKey`).
pub fn changed_from_defaults(&self) -> bool {
self.keys.keys().any(|name| !self.is_default(name))
}
pub fn set(&mut self, name: &str, key: &str) {
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
self.keys.insert(n, key.to_owned());
@@ -651,22 +645,6 @@ mod tests {
assert_eq!(k.translate_all(KeyCode::Digit9), [Ok(KeyCode::Digit9)]);
assert_eq!(k.translate_all(KeyCode::F3), [Ok(KeyCode::F3)]);
}
/// `KeyBindsScreen.extractRenderState`: Reset All is active only while a
/// binding differs from its default.
#[test]
fn reset_all_is_active_only_after_a_binding_moves() {
let mut keys = KeyBinds::default();
assert!(!keys.changed_from_defaults());
keys.set("key.attack", "key.mouse.right");
assert!(keys.changed_from_defaults());
// Setting a mapping back to its own default leaves nothing to reset.
keys.set("key.attack", KeyBinds::default_of("key.attack"));
assert!(!keys.changed_from_defaults());
keys.set("key.jump", "key.keyboard.z");
keys.reset_all();
assert!(!keys.changed_from_defaults());
}
#[test]
fn display_names() {
assert_eq!(display_name("key.keyboard.w").plain(), "W");
+2 -9
View File
@@ -22,14 +22,9 @@ horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
# once, not for every phase of every tick.
rayon = "1.10"
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
@@ -39,6 +34,8 @@ horizon-server-files = { path = "../horizon-server-files" }
getrandom = "0.3"
# 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).
@@ -133,10 +130,6 @@ 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"
+1
View File
@@ -9,6 +9,7 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
@@ -92,9 +92,6 @@ pub struct BlockEntities {
/// 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>,
/// The signs a player's editor is on (`SignBlockEntity.playerWhoMayEdit`
/// set), so the level's tick checks those alone instead of every sign.
sign_editors: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
@@ -315,44 +312,6 @@ impl BlockEntities {
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
.flat_map(|(_, set)| set)
}
/// `SignBlockEntity.playerWhoMayEdit` of the sign at `pos`.
pub fn sign_editor(&self, pos: &Pos) -> Option<u128> {
match self.map.get(pos) {
Some(BlockEntity::Sign(sign)) => sign.allowed_editor,
_ => None,
}
}
/// `SignBlockEntity.setAllowedPlayerEditor`: the one player whose editor
/// is on the sign at `pos`, or `None` for nobody's.
pub fn set_sign_editor(&mut self, pos: Pos, who: Option<u128>) {
let Some(BlockEntity::Sign(sign)) = self.map.get_mut(&pos) else {
return;
};
sign.allowed_editor = who;
if who.is_some() {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// The positions of the signs a player's editor is on, in no order.
pub fn sign_editors(&self) -> impl Iterator<Item = &Pos> {
self.sign_editors.iter()
}
/// Drops `pos` from the signs an editor is on, for a sign that is gone or
/// no longer holds one. `set_sign_editor` keeps the list itself.
pub fn forget_sign_editor(&mut self, pos: Pos) {
self.sign_editors.remove(&pos);
}
/// Keeps `pos` listed in `sign_editors` exactly while `entity` holds an
/// editor, as `note_box` does for the boxes.
fn note_sign_editor(&mut self, pos: Pos, entity: &BlockEntity) {
if matches!(entity, BlockEntity::Sign(sign) if sign.allowed_editor.is_some()) {
self.sign_editors.insert(pos);
} else {
self.sign_editors.remove(&pos);
}
}
/// `LevelChunk.getBlockEntity`: the block entity at `pos`, if any.
pub fn get(&self, pos: &Pos) -> Option<&BlockEntity> {
self.map.get(pos)
@@ -392,7 +351,6 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.note_sign_editor(pos, &entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
@@ -440,7 +398,6 @@ impl BlockEntities {
forget_mod(&mut self.mods, *pos);
}
self.boxes.remove(pos);
self.sign_editors.remove(pos);
removed
}
/// The block entity at `pos`, out of the map for a while: its slot stays
@@ -461,7 +418,6 @@ impl BlockEntities {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
self.note_box(pos, Some(&entity));
self.note_sign_editor(pos, &entity);
if self.map.insert(pos, entity).is_none()
&& !self.live.contains_key(&pos)
&& !self.pending.contains_key(&pos)
@@ -489,7 +445,6 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
let sign_editors = &mut self.sign_editors;
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
@@ -499,7 +454,6 @@ impl BlockEntities {
pending.remove(p);
forget_mod(mods, *p);
boxes.remove(p);
sign_editors.remove(p);
}
kept
});
@@ -509,7 +463,6 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.sign_editors.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
@@ -531,7 +484,6 @@ impl BlockEntities {
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
self.note_sign_editor(p, &e);
let old = self.map.insert(p, e);
if old.is_some() {
self.replaced.insert(p);
@@ -15,7 +15,7 @@ use crate::{
font::{Font, Glyph},
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex, cull_back},
mesher::{CULL_BACK, Vertex},
models::load_visibility,
signs::{self, SignData, SignText},
text::{Style, Text},
@@ -3093,9 +3093,9 @@ impl BlockEntityRenderer {
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each (#15322).
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = cull_back(vertex.normal);
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
@@ -4760,12 +4760,9 @@ mod tests {
28,
"the pot submits all of vanilla's quads"
);
// A part the shader shades keeps its normal (#15322): `w` alone says cull.
assert!(
verts
.iter()
.all(|v| v.normal[3] == crate::mesher::SHADED_CULL_W && v.normal[..3] != [0; 3]),
"entitySolid culls the pot's back faces and shades it by its normals"
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return frame_stored(border_color(in));
return border_color(in);
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
+4 -11
View File
@@ -568,8 +568,6 @@ impl Chat {
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
}
@@ -595,25 +593,20 @@ impl Chat {
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
/// `resetChatScroll`, then the messages the Chat option shows.
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.scroll = 0;
self.new_since_scroll = false;
self.rewrap(&atlas.font);
}
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
/// where the reader was (`chatScrollbarPos` survives a filter change);
/// `rescale` resets the scroll itself, as `rescaleChat` does.
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
@@ -1067,34 +1067,6 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
);
}
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
/// `rescaleChat` resets them, after `resetChatScroll`.
#[test]
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
let font = font();
let mut chat = Chat::default();
for i in 0..60 {
chat.add_message(
&font,
Text::literal(format!("line {i}")),
None,
ChatSource::Server,
0,
false,
);
}
chat.scroll = 3;
chat.new_since_scroll = true;
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(chat.scroll, 3);
assert!(chat.new_since_scroll);
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
chat.rescale(&atlas);
assert_eq!(chat.scroll, 0);
assert!(!chat.new_since_scroll);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
+21 -184
View File
@@ -4,7 +4,6 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -196,7 +195,7 @@ impl ColumnLayout {
}
/// The index of the section holding block `y`, if the column holds it.
pub fn section_index(self, y: i32) -> Option<usize> {
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections)
}
@@ -458,44 +457,6 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -613,10 +574,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface marks that column, found again on its next read.
surface: Heights,
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: Heights,
motion_blocking: OnceLock<Box<[i16; 256]>>,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -657,8 +618,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: Heights::default(),
motion_blocking: Heights::default(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
@@ -700,7 +661,7 @@ impl Chunk {
/// The index of the section holding block `y`, if the column holds it.
#[inline]
pub fn section_index(&self, y: i32) -> Option<usize> {
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
usize::try_from((y - self.min_y) >> 4)
.ok()
.filter(|&s| s < self.sections.len())
}
@@ -737,7 +698,12 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
map.write(column(x, z), y);
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
}
}
previous
@@ -748,55 +714,24 @@ impl Chunk {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
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.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
self.height(&self.surface, x, z, |v| !air(v))
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -862,7 +797,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.heights.get().is_some())
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -984,24 +919,6 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
let mut chunk = Chunk::with_layout(0, 0, layout);
chunk.set(0, layout.min_y(), 0, 7);
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
assert_eq!(layout.section_index(y), None);
assert_eq!(chunk.section_index(y), None);
assert_eq!(chunk.get(0, y, 0), 0);
assert_eq!(
chunk.light(0, y, 0),
if y < layout.min_y() { 0 } else { 0xf0 }
);
chunk.set(0, y, 0, 9);
}
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
}
}
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
@@ -1103,89 +1020,9 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.heights.get().is_some());
assert!(chunk.surface.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
@@ -513,10 +513,6 @@ pub struct SignData {
pub waxed: bool,
/// `allow_op_features`: the sign's click events may run commands.
pub allow_op_features: bool,
/// `playerWhoMayEdit`: the one player whose editor is on this sign, as a
/// UUID. Every other player's click, write and applicator is refused
/// while it is set. Not saved (vanilla keeps it out of the NBT).
pub allowed_editor: Option<u128>,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
+2 -11
View File
@@ -419,10 +419,8 @@ impl SceneCollisions<'_, '_> {
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
.offset(1, 1, 1);
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
.into_iter()
.try_fold(1i64, |n, size| n.checked_mul(size));
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
return false;
}
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
@@ -1113,13 +1111,6 @@ mod section_tests {
binding: &binding,
subject: None,
};
// The fast path must decline an enormous volume without overflowing
// its size calculation or beginning to walk it.
let enormous = Aabb::new(
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
Vec3::new(1.0e9, 1.0e9, 1.0e9),
);
assert!(!scene.cells_without_collision(enormous));
// Under the fence: its section's outer cells may hold a shape reaching in.
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
+14 -85
View File
@@ -56,8 +56,6 @@ pub struct Overlay {
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
chain: Vec<(u32, u32)>,
overlay: OverlayJoin,
}
@@ -190,7 +188,6 @@ impl ConnectedTextures {
Ok(tiles) => Some(LoadedRule {
rule,
tiles,
chain: Vec::new(),
overlay: OverlayJoin::default(),
}),
Err(e) => {
@@ -295,23 +292,10 @@ impl ConnectedTextures {
let mut by_layer: Vec<Vec<u16>> = Vec::new();
// Block rules by the block each entry names, so a state tests only its own.
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
for (i, r) in out.rules.iter().enumerate() {
let i = i as u16;
for tile in &r.rule.match_tiles {
let name = sprite_name(tile);
let list = by_name.entry(name.clone()).or_default();
if list.last() != Some(&i) {
list.push(i);
}
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
// name. By layer it would match every face whose sprite has the same pixels
// (identical stills share a layer: Conquest's blank placeholders, dozens).
if !name.contains(':') {
continue;
}
if let Some(l) = layer(&name) {
if let Some(l) = layer(&sprite_name(tile)) {
let l = l as usize;
if by_layer.len() <= l {
by_layer.resize(l + 1, Vec::new());
@@ -332,26 +316,6 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_layer.push(range);
}
let by_name: HashMap<String, (u32, u32)> = by_name
.into_iter()
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
.collect();
// Set at the end: `by_block` borrows the rules' names.
let chains: Vec<Vec<(u32, u32)>> = out
.rules
.iter()
.map(|r| {
r.rule
.tiles
.iter()
.map(|t| match t {
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
_ => None,
})
.map(Option::unwrap_or_default)
.collect()
})
.collect();
let mut list = Vec::new();
for state in states {
let Some((name, props)) = state else {
@@ -391,9 +355,6 @@ impl ConnectedTextures {
let range = push_list(&mut out.lists, &list);
out.by_state.push(range);
}
for (r, chain) in out.rules.iter_mut().zip(chains) {
r.chain = chain;
}
out
}
@@ -431,11 +392,9 @@ impl ConnectedTextures {
}
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
ranges.get(i).map_or(&[], |&range| self.range(range))
}
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
&self.lists[start as usize..(start + len) as usize]
ranges.get(i).map_or(&[], |&(start, len)| {
&self.lists[start as usize..(start + len) as usize]
})
}
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
@@ -482,7 +441,7 @@ impl ConnectedTextures {
let r = &self.rules[usize::from(i)];
if (block || r.rule.match_blocks.is_empty())
&& (tile || r.rule.match_tiles.is_empty())
&& let Some((pick, index)) = self.apply(
&& let Some(pick) = self.apply(
r,
state,
layer,
@@ -495,7 +454,7 @@ impl ConnectedTextures {
)
{
// "Tiles output by CTM rules can also be matched by another rule": once, by the
// tile rules naming the picked tile by name (and this block, if they name blocks).
// tile rules naming the picked tile (and this block, if they name blocks).
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
return pick;
@@ -508,7 +467,7 @@ impl ConnectedTextures {
// again: an overlay would draw twice.
let first = self.list(&self.by_layer, layer as usize);
let again = self
.range(r.chain.get(index).copied().unwrap_or_default())
.list(&self.by_layer, picked as usize)
.iter()
.map(|&j| (j, &self.rules[usize::from(j)]))
.filter(|&(j, r)| {
@@ -529,8 +488,8 @@ impl ConnectedTextures {
&texture,
&mut overlay,
) {
Some((Pick::Keep, _)) => return pick,
Some((chained, _)) => return chained,
Some(Pick::Keep) => return pick,
Some(chained) => return chained,
None => {}
}
}
@@ -553,7 +512,7 @@ impl ConnectedTextures {
biome: &impl Fn() -> Option<&'b str>,
texture: &impl Fn() -> Option<Axes>,
overlay: &mut impl FnMut(Overlay),
) -> Option<(Pick, usize)> {
) -> Option<Pick> {
let rule = &r.rule;
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
// a custom name are not loaded.
@@ -643,7 +602,7 @@ impl ConnectedTextures {
return if quarters.contains(&TileLayer::Skip) {
None
} else {
Some((Pick::Quarters(quarters), 0))
Some(Pick::Quarters(quarters))
};
}
}
@@ -658,7 +617,7 @@ impl ConnectedTextures {
return None;
}
if !joins([0, 1, 0]) {
return Some((Pick::Keep, 0));
return Some(Pick::Keep);
}
0
}
@@ -685,8 +644,8 @@ impl ConnectedTextures {
}
match tile(index) {
TileLayer::Skip => None,
TileLayer::Default => Some((Pick::Keep, index)),
t => Some((Pick::Tile(t), index)),
TileLayer::Default => Some(Pick::Keep),
t => Some(Pick::Tile(t)),
}
}
}
@@ -716,7 +675,6 @@ mod tests {
.enumerate()
.map(|(n, rule)| LoadedRule {
overlay: OverlayJoin::default(),
chain: Vec::new(),
tiles: rule
.tiles
.iter()
@@ -764,10 +722,6 @@ mod tests {
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
// And `a0` of the first rule.
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
"assets/minecraft/optifine/ctm/grass/1.png"
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
_ => None,
},
)
@@ -1222,31 +1176,6 @@ mod tests {
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
}
#[test]
fn chaining_follows_the_tile_not_its_pixels() {
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
// first; it must not draw on the grass.
let ctm = world(&[
(
"grass/top.properties",
"matchTiles=glass\nmethod=fixed\ntiles=1",
),
(
"grass/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
),
(
"aaa/random_1.properties",
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
),
]);
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
// A face whose own sprite has a placeholder's pixels is no placeholder.
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
}
#[test]
fn orient_texture_follows_the_quads_uvs() {
let rule = |orient: &str| {
+44 -324
View File
@@ -4,13 +4,11 @@
//!
//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or
//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads:
//! - GLSL vanilla's GL compiler takes and naga doesn't is rewritten first ([`glsl_fixups`]: a
//! helper taking the `sampler2D` it reads, an array sized by its initializer only);
//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`;
//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split
//! into a texture and a sampler object, combined again where the sampler is named;
//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main`
//! (renamed `hz_main`) runs, and `gl_VertexID` and `gl_VertexIndex` read the glue's expression;
//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression;
//! - varyings get locations by name across the stages, the fragment output location 0.
use std::collections::HashSet;
@@ -20,8 +18,6 @@ use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
mod glsl_fixups;
/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0.
pub const BLOCKS: [(&str, u32); 4] = [
("Globals", 0),
@@ -47,7 +43,7 @@ pub struct Glue<'a> {
pub setup: &'a str,
/// Statements after it, on `gl_Position`.
pub finish: &'a str,
/// The expression the pack's `gl_VertexID` and `gl_VertexIndex` read.
/// The expression `gl_VertexID` reads.
pub vertex_id: &'a str,
}
@@ -56,15 +52,6 @@ pub struct Glue<'a> {
pub struct CoreProgram {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
/// binding unused).
pub other: Vec<String>,
}
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
pub fn other_binding(index: usize) -> u32 {
OTHER_BINDINGS + 2 * index as u32
}
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
@@ -75,39 +62,19 @@ pub fn translate_core(
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
) -> Result<CoreProgram, String> {
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
}
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
pub fn translate_core_with(
vertex: &str,
fragment: &str,
defines: &[&str],
include: &dyn Fn(&str) -> Option<String>,
glue: &Glue,
provided: &dyn Fn(&str) -> bool,
) -> Result<CoreProgram, String> {
const CORE: &str = "minecraft:shaders/core/";
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
let fragment_text = glsl_fixups::fixups(&strip_comments(&expand(fragment, CORE, include)?));
let vertex_text = strip_comments(&expand(vertex, CORE, include)?);
let fragment_text = strip_comments(&expand(fragment, CORE, include)?);
let mut names = Names::default();
let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
.map_err(|e| format!("fragment shader: {e}"))?;
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
.map_err(|e| format!("vertex shader: {e}"))?;
let fragment = compile(
ShaderStage::Fragment,
&fragment_code,
defines,
&names,
provided,
)
.map_err(|e| format!("fragment shader: {e}"))?;
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
.map_err(|e| format!("fragment shader: {e}"))?;
let given = locations(&vertex, false);
if let Some(missing) = locations(&fragment, true)
.into_iter()
@@ -117,21 +84,11 @@ pub fn translate_core_with(
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
));
}
Ok(CoreProgram {
vertex,
fragment,
other: names.other,
})
Ok(CoreProgram { vertex, fragment })
}
/// A bound on a pack's includes, which are pasted every time.
const MAX_PASTES: u32 = 1024;
const MAX_EXPANDED: usize = 1 << 20;
/// The file with its includes inlined (`#include <ns:path>`, `#include "relative"` and the same
/// with `#moj_import`), `#version` and `#extension` lines dropped. An `#include` is pasted every
/// time, as vanilla's shaderc does (the files' `#ifndef` guards keep one copy); a `#moj_import`
/// once.
/// with `#moj_import`), each file once, `#version` and `#extension` lines dropped.
fn expand(
text: &str,
dir: &str,
@@ -142,7 +99,6 @@ fn expand(
dir: &str,
include: &dyn Fn(&str) -> Option<String>,
seen: &mut HashSet<String>,
pastes: &mut u32,
depth: u32,
out: &mut String,
) -> Result<(), String> {
@@ -153,19 +109,15 @@ fn expand(
let directive = line.trim_start().strip_prefix('#').map(str::trim_start);
let target = directive.and_then(|d| {
d.strip_prefix("include")
.map(|t| (t.trim(), false))
.or_else(|| d.strip_prefix("moj_import").map(|t| (t.trim(), true)))
.or_else(|| d.strip_prefix("moj_import"))
.map(str::trim)
});
if let Some((target, once)) = target {
if let Some(target) = target {
let id = include_id(target, dir).ok_or_else(|| format!("bad include {target}"))?;
if seen.insert(id.clone()) || !once {
*pastes += 1;
if *pastes > MAX_PASTES {
return Err(format!("includes paste more than {MAX_PASTES} files"));
}
if seen.insert(id.clone()) {
let file = include(&id).ok_or_else(|| format!("{id} not found"))?;
let parent = &id[..id.rfind('/').map_or(id.len(), |i| i + 1)];
walk(&file, parent, include, seen, pastes, depth + 1, out)?;
walk(&file, parent, include, seen, depth + 1, out)?;
}
out.push('\n');
} else if directive
@@ -176,16 +128,11 @@ fn expand(
out.push_str(line);
out.push('\n');
}
if out.len() > MAX_EXPANDED {
return Err(format!(
"includes grow the shader past {MAX_EXPANDED} bytes"
));
}
}
Ok(())
}
let mut out = String::new();
walk(text, dir, include, &mut HashSet::new(), &mut 0, 0, &mut out)?;
walk(text, dir, include, &mut HashSet::new(), 0, &mut out)?;
Ok(out)
}
@@ -342,16 +289,14 @@ fn block_name(line: &str) -> Option<&str> {
let rest = line[at + "uniform".len()..].trim();
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
let (name, tail) = rest.split_at(name_end);
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
.then_some(name)
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
}
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
/// What the two stages share: the vertex stage's outputs in location order, and the names of
/// the blocks and samplers of other pipelines in binding order.
#[derive(Default)]
struct Names {
varyings: Vec<(String, u32)>,
varyings: Vec<String>,
other: Vec<String>,
}
@@ -359,8 +304,8 @@ impl Names {
fn location(&self, varying: &str) -> Option<u32> {
self.varyings
.iter()
.find(|(v, _)| v == varying)
.map(|(_, location)| *location)
.position(|v| v == varying)
.map(|i| i as u32)
}
/// A block or sampler of another pipeline: its binding (two for a sampler).
@@ -372,7 +317,7 @@ impl Names {
self.other.len() - 1
}
};
other_binding(index)
OTHER_BINDINGS + 2 * index as u32
}
}
@@ -392,19 +337,7 @@ fn rewrite(
writeln!(out, "{ty} {name};").unwrap();
}
}
// Where the pack's own code starts: the glue above it is left as it is.
let pack_start = out.len();
let mut outputs: Vec<&str> = Vec::new();
let explicit: Vec<u32> = if vertex {
text.lines()
.flat_map(declaration_lines)
.filter_map(declaration)
.filter(|d| d.storage == "out")
.filter_map(|d| d.location)
.collect()
} else {
Vec::new()
};
for source_line in text.lines() {
for line in declaration_lines(source_line) {
if let Some(name) = block_name(line) {
@@ -484,21 +417,10 @@ fn rewrite(
}
"out" if vertex => {
// One location each: naga takes scalars and vectors only.
let location = decl
.location
.or_else(|| names.location(decl.name))
.unwrap_or_else(|| {
(0..)
.find(|l| {
!explicit.contains(l)
&& names.varyings.iter().all(|(_, v)| v != l)
})
.unwrap_or_default()
});
let varying = (decl.name.to_owned(), location);
if !names.varyings.contains(&varying) {
names.varyings.push(varying);
}
let location = names.location(decl.name).unwrap_or_else(|| {
names.varyings.push(decl.name.to_owned());
names.varyings.len() as u32 - 1
});
writeln!(
out,
"layout(location = {location}) {qualifiers} out {} {};",
@@ -507,12 +429,9 @@ fn rewrite(
.unwrap();
}
"in" => {
let location = decl
.location
.or_else(|| names.location(decl.name))
.ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
let location = names.location(decl.name).ok_or_else(|| {
format!("input {} isn't written by the vertex shader", decl.name)
})?;
writeln!(
out,
"layout(location = {location}) {qualifiers} in {} {};",
@@ -543,18 +462,11 @@ fn rewrite(
if !vertex {
return Ok(out);
}
// The pack's `gl_VertexID` and 26.3's `gl_VertexIndex` both mean vanilla's vertex number
// (four per quad, an `int`): both read the glue's expression, which for a pipeline that
// draws six vertices per quad turns its own `gl_VertexIndex` back into that number. Only the
// pack's code is rewritten, so the glue's expression keeps reading the real index.
let vertex_id = format!("({})", glue.vertex_id);
let pack = replace_ident(
&rename_main(&out[pack_start..]),
"gl_VertexIndex",
&vertex_id,
let mut code = replace_ident(
&rename_main(&out),
"gl_VertexID",
&format!("({})", glue.vertex_id),
);
let pack = replace_ident(&pack, "gl_VertexID", &vertex_id);
let mut code = format!("{}{pack}", &out[..pack_start]);
write!(
code,
"void main() {{\n{}\nhz_main();\n{}\n}}\n",
@@ -586,17 +498,10 @@ fn rename_main(text: &str) -> String {
}
/// Parses and validates one stage; errors name the line of the rewritten code.
fn compile(
stage: ShaderStage,
code: &str,
defines: &[&str],
names: &Names,
provided: &dyn Fn(&str) -> bool,
) -> Result<naga::Module, String> {
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
let mut options = Options::from(stage);
for define in defines {
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
options.defines.insert(name.to_owned(), value.to_owned());
options.defines.insert((*define).to_owned(), String::new());
}
let module = Frontend::default().parse(&options, code).map_err(|e| {
e.errors
@@ -624,16 +529,16 @@ fn compile(
})?;
let entry = info.get_entry_point(0);
for (handle, global) in module.global_variables.iter() {
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
continue;
};
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
// The name it was declared with (a block's instance may have none).
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
let name = names.other.get(index).map_or("?", String::as_str);
if provided(name) {
continue;
}
let other = global
.binding
.as_ref()
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
if other && !entry[handle].is_empty() {
let name = global.name.as_deref().unwrap_or("?");
let name = name
.strip_prefix("hz_tex_")
.or_else(|| name.strip_prefix("hz_smp_"))
.unwrap_or(name);
return Err(format!("{name} is not a block or sampler of this pipeline"));
}
}
@@ -728,44 +633,6 @@ mod tests {
assert_eq!(program.fragment.entry_points.len(), 1);
}
/// A 26.3 pack's `gl_VertexIndex` means vanilla's vertex number, four per quad. The text
/// pipeline draws six per glyph, so the pack's code must read the glue's `hz_vertex_id()`
/// (as `gl_VertexID` does), never the raw index.
#[test]
fn a_packs_gl_vertex_index_reads_the_glues_vertex_number() {
let vsh = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n#include <minecraft:projection.glsl>\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\nlayout(location = 2) in vec2 UV0;\n\
layout(location = 2) out vec4 vertexColor;\nlayout(location = 3) out vec2 texCoord0;\n\
void main() {\n int corner = gl_VertexIndex % 4;\n\
gl_Position = ProjMat * ModelViewMat * vec4(Position + vec3(float(corner), 0.0, 0.0), 1.0);\n\
vertexColor = Color;\n texCoord0 = UV0;\n}\n";
let program = translate_core(vsh, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap();
let module = &program.vertex;
let named = |name: &str| {
module
.functions
.iter()
.find(|(_, f)| f.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("no {name}"))
};
let (vertex_id, _) = named("hz_vertex_id");
let (_, pack_main) = named("hz_main");
fn calls(block: &naga::Block, target: naga::Handle<naga::Function>) -> bool {
block.iter().any(|statement| match statement {
naga::Statement::Call { function, .. } => *function == target,
naga::Statement::Block(inner) => calls(inner, target),
naga::Statement::If { accept, reject, .. } => {
calls(accept, target) || calls(reject, target)
}
naga::Statement::Loop {
body, continuing, ..
} => calls(body, target) || calls(continuing, target),
_ => false,
})
}
assert!(calls(&pack_main.body, vertex_id));
}
/// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat
/// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`.
#[test]
@@ -799,96 +666,6 @@ mod tests {
assert_eq!(read, [0, 2]);
}
#[test]
fn explicit_varying_locations_are_kept() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 4) out float gui;\n#else\nlayout(location = 4) out float world;\n#endif\n\
layout(location = 13) out vec4 a;\nout float b;\n\
void main() { gl_Position = vec4(Position, 1.0); a = vec4(1.0); b = 1.0;\n\
#ifdef IS_GUI\ngui = 1.0;\n#else\nworld = 1.0;\n#endif\n}\n";
let fsh = "#version 330\n#ifdef IS_GUI\nlayout(location = 4) in float gui;\n#endif\n\
layout(location = 13) in vec4 a;\nin float b;\nout vec4 fragColor;\n\
void main() { fragColor = a * b;\n#ifdef IS_GUI\nfragColor *= gui;\n#endif\n}\n";
let program = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
let mut read = locations(&program.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 4, 13]);
}
#[test]
fn an_include_is_pasted_every_time_and_a_moj_import_once() {
let file = |id: &str| {
(id == "minecraft:shaders/include/g.glsl")
.then(|| "#ifndef G\n#define G\nconst float g = 1.0;\n#endif\n".to_owned())
};
let twice =
"#ifdef A\n#include <minecraft:g.glsl>\n#else\n#include <minecraft:g.glsl>\n#endif\n";
let expanded = expand(twice, "minecraft:shaders/core/", &file).unwrap();
assert_eq!(expanded.matches("const float g").count(), 2);
let imported = expand(
&twice.replace("#include", "#moj_import"),
"minecraft:shaders/core/",
&file,
)
.unwrap();
assert_eq!(imported.matches("const float g").count(), 1);
}
#[test]
fn a_location_another_branch_gave_the_same_name_isnt_reused() {
let vsh = "#version 330\nin vec3 Position;\n\
#ifdef IS_GUI\nlayout(location = 0) out vec4 vertexColor;\n\
#else\nlayout(location = 1) out vec4 vertexColor;\nlayout(location = 0) out vec4 worldOther;\n#endif\n\
void main() { gl_Position = vec4(Position, 1.0); vertexColor = vec4(1.0);\n\
#ifndef IS_GUI\nworldOther = vec4(0.5);\n#endif\n}\n";
let fsh = "#version 330\n\
#ifdef IS_GUI\nlayout(location = 0) in vec4 vertexColor;\n\
#else\nlayout(location = 1) in vec4 vertexColor;\nlayout(location = 0) in vec4 worldOther;\n#endif\n\
out vec4 fragColor;\nvoid main() { fragColor = vertexColor;\n\
#ifndef IS_GUI\nfragColor *= worldOther;\n#endif\n}\n";
let world = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
let mut written = locations(&world.vertex, false);
written.sort_unstable();
assert_eq!(written, [0, 1]);
let mut read = locations(&world.fragment, true);
read.sort_unstable();
assert_eq!(read, [0, 1]);
let gui = translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap();
assert_eq!(locations(&gui.vertex, false), [0]);
}
#[test]
fn includes_that_multiply_are_refused_once_over_budget() {
let file = |id: &str| {
let level: u32 = id
.strip_prefix("minecraft:shaders/include/l")?
.strip_suffix(".glsl")?
.parse()
.ok()?;
let next = format!("#include <minecraft:l{}.glsl>\n", level + 1).repeat(4);
Some(format!(
"#ifndef L{level}\n#define L{level}\n{}#endif\n",
if level < 15 { next.as_str() } else { "" }
))
};
let error = expand(
"#include <minecraft:l0.glsl>\n",
"minecraft:shaders/core/",
&file,
)
.unwrap_err();
assert!(error.contains("paste more than"), "{error}");
}
#[test]
fn a_define_with_a_value_is_defined_to_it() {
let vsh = "#version 330\nin vec3 Position;\n\
void main() { gl_Position = vec4(Position, float(COUNT)); }\n";
let fsh =
"#version 330\nout vec4 fragColor;\nvoid main() { fragColor = vec4(COUNT / 4); }\n";
assert!(translate_core(vsh, fsh, &["COUNT 8"], &include, &GLUE).is_ok());
}
#[test]
fn minified_varying_declarations_are_rewritten() {
let vsh = "#version 330\nout float sphericalVertexDistance;out float cylindricalVertexDistance;\
@@ -909,63 +686,6 @@ mod tests {
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
}
/// A block declared on one line, as packs write a one-float uniform, is bound like one
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
/// order, and one it doesn't provide is not.
#[test]
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
let both = |name: &str| name == "Day" || name == "Bounds";
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
assert_eq!(program.other, ["Day", "Bounds"]);
// The bindings the caller gives them at.
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
module
.global_variables
.iter()
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
.collect()
};
assert!(
bindings(&program.vertex)
.iter()
.any(|(_, b)| *b == other_binding(0)),
"{:?}",
bindings(&program.vertex)
);
let fragment = bindings(&program.fragment);
assert!(
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
"{fragment:?}"
);
assert!(
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
"{fragment:?}"
);
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
assert!(error.contains("Day is not a block or sampler"), "{error}");
let only_day = |name: &str| name == "Day";
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
assert!(
error.contains("Bounds is not a block or sampler"),
"{error}"
);
}
/// Only a uniform block's declaration names a block, written over several lines or on one.
#[test]
fn block_names_are_read_from_block_declarations_only() {
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
assert_eq!(block_name("uniform Globals"), Some("Globals"));
assert_eq!(block_name("uniform float x;"), None);
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
}
#[test]
fn what_horizon_does_not_provide_is_an_error() {
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
@@ -1,603 +0,0 @@
//! GLSL that vanilla's GL compiler takes and naga's front end doesn't (#9386), rewritten into
//! what both take:
//! - a function with `sampler2D` parameters is copied once per set of samplers its callers pass
//! (all of them a shader's global samplers), the copy reading those samplers by name;
//! - an unsized array (`const float[] w = float[](...)`, `float w[] = ...`) takes the size its
//! initializer gives.
/// The longest shader rewritten (vanilla's, includes expanded, are a few KiB).
const MAX_LEN: usize = 64 * 1024;
/// The bytes the rewrite may scan for calls (the whole text per call it rewrites): thousands of
/// calls leave the text as it was rather than freeze a load.
const SCAN_BUDGET: usize = 64 << 20;
/// `text` with the rewrites, where they apply; anything they can't follow is left as it was.
pub(super) fn fixups(text: &str) -> String {
if text.len() > MAX_LEN {
return text.to_owned();
}
size_unsized_arrays(&specialize_sampler_params(text))
}
fn is_ident(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// A name a sampler can have (`sampler2D ;` gives none, `t[2]` isn't one).
fn is_name(s: &str) -> bool {
s.chars().all(is_ident) && s.starts_with(|c: char| !c.is_ascii_digit()) && s != "sampler2D"
}
/// The identifier that ends right before byte `end` (whitespace skipped), and where it starts.
fn ident_before(text: &str, end: usize) -> Option<(usize, &str)> {
let trimmed = text[..end].trim_end();
let start = trimmed
.char_indices()
.rev()
.take_while(|(_, c)| is_ident(*c))
.last()?
.0;
let ident = &trimmed[start..];
(!ident.is_empty() && !ident.starts_with(|c: char| c.is_ascii_digit()))
.then_some((start, ident))
}
/// For each byte of `text`, the byte of the bracket closing the one opening there (counting
/// that kind of bracket only), or `None`: one pass, so a scan asking for many stays linear.
fn closings(text: &str) -> Vec<Option<usize>> {
let mut out = vec![None; text.len()];
let mut open: [Vec<usize>; 3] = Default::default();
for (i, b) in text.bytes().enumerate() {
let (kind, opens) = match b {
b'(' => (0, true),
b')' => (0, false),
b'[' => (1, true),
b']' => (1, false),
b'{' => (2, true),
b'}' => (2, false),
_ => continue,
};
if opens {
open[kind].push(i);
} else if let Some(at) = open[kind].pop() {
out[at] = Some(i);
}
}
out
}
/// The comma-separated parts of `list` at its own nesting level.
fn split_top(list: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut start) = (0i32, 0);
for (i, c) in list.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
',' if depth == 0 => {
parts.push(&list[start..i]);
start = i + 1;
}
_ => {}
}
}
if !list[start..].trim().is_empty() || !parts.is_empty() {
parts.push(&list[start..]);
}
parts
}
/// A function at the file's top level whose parameters include a `sampler2D`.
struct SamplerFn {
name: String,
/// The header from its return type to its `{` (or `;` for a prototype), and the body after.
start: usize,
end: usize,
params: Vec<String>,
/// The indices of the `sampler2D` parameters and their names.
samplers: Vec<(usize, String)>,
/// `None` for a prototype.
body: Option<String>,
return_type: String,
}
fn sampler_functions(text: &str) -> Vec<SamplerFn> {
let closing = closings(text);
let mut out = Vec::new();
let mut depth = 0i32;
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'{' => depth += 1,
b'}' => depth -= 1,
b'(' if depth == 0 => {
if let (Some((name_at, name)), Some(close)) = (ident_before(text, i), closing[i])
&& let Some((type_at, return_type)) = ident_before(text, name_at)
{
let params: Vec<String> = split_top(&text[i + 1..close])
.into_iter()
.map(|p| p.trim().to_owned())
.collect();
let samplers: Vec<(usize, String)> = params
.iter()
.enumerate()
.filter(|(_, p)| p.split_whitespace().any(|w| w == "sampler2D"))
.map(|(n, p)| (n, p.split_whitespace().last().unwrap_or("").to_owned()))
.collect();
// One without a name is left for naga to refuse.
if !samplers.iter().all(|(_, name)| is_name(name)) {
i = close + 1;
continue;
}
let after = text[close + 1..].trim_start();
let next = close + 1 + (text[close + 1..].len() - after.len());
if !samplers.is_empty() && (after.starts_with('{') || after.starts_with(';')) {
let (end, body) = if after.starts_with('{') {
let Some(body_end) = closing[next] else {
break;
};
(body_end + 1, Some(text[next..=body_end].to_owned()))
} else {
(next + 1, None)
};
out.push(SamplerFn {
name: name.to_owned(),
start: type_at,
end,
params,
samplers,
body,
return_type: return_type.to_owned(),
});
i = end;
continue;
}
}
// Any other header: no other one sits in its parentheses.
if let Some(close) = closing[i] {
i = close + 1;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// The calls to `name` outside `skip` (its own header): each call's span and arguments. The
/// bytes read are added to `scanned`.
fn calls<'a>(
scanned: &std::cell::Cell<usize>,
text: &'a str,
name: &str,
skip: &[(usize, usize)],
) -> Vec<(usize, usize, Vec<&'a str>)> {
scanned.set(scanned.get() + text.len());
let closing = closings(text);
let mut out = Vec::new();
let mut from = 0;
while let Some(found) = text[from..].find(name) {
let at = from + found;
from = at + name.len();
let before = text[..at].chars().next_back();
let rest = &text[at + name.len()..];
let open = at + name.len() + (rest.len() - rest.trim_start().len());
if before.is_some_and(is_ident)
|| !text[open..].starts_with('(')
|| skip.iter().any(|(s, e)| (*s..*e).contains(&at))
{
continue;
}
let Some(close) = closing[open] else {
continue;
};
scanned.set(scanned.get() + close - open);
out.push((at, close + 1, split_top(&text[open + 1..close])));
}
out
}
/// Every `from` standing as an identifier on its own, but not a member after a `.` (a
/// parameter `s` leaves the swizzle `c.s` alone).
fn replace_ident(text: &str, from: &str, to: &str) -> String {
if from.is_empty() {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut done = 0;
while let Some(found) = text[done..].find(from) {
let at = done + found;
// Read in the whole text: a match right after another (`ss`) is part of it.
let before = text[..at].trim_end().chars().next_back();
let joined = text[..at].chars().next_back().is_some_and(is_ident);
let after = text[at + from.len()..].chars().next();
out.push_str(&text[done..at]);
if joined || before == Some('.') || after.is_some_and(is_ident) {
out.push_str(from);
} else {
out.push_str(to);
}
done = at + from.len();
}
out.push_str(&text[done..]);
out
}
/// A body with its sampler parameters reading `chosen`, all at once: a parameter named like
/// another's sampler (`mix2(sampler2D Sampler0, sampler2D Sampler1)` called with them swapped)
/// still reads its own.
fn read_samplers(body: &str, params: &[(usize, String)], chosen: &[String]) -> String {
let mut body = body.to_owned();
for (n, (_, param)) in params.iter().enumerate() {
body = replace_ident(&body, param, &format!("hz_sampler_param_{n}"));
}
for (n, sampler) in chosen.iter().enumerate().take(params.len()) {
body = replace_ident(&body, &format!("hz_sampler_param_{n}"), sampler);
}
body
}
/// The byte after `uniform sampler2D <name>;`, where the shader declares that sampler.
fn sampler_declaration_end(text: &str, name: &str) -> Option<usize> {
let mut from = 0;
while let Some(found) = text[from..].find("sampler2D") {
let at = from + found;
from = at + "sampler2D".len();
let before = text[..at].trim_end();
if !before.ends_with("uniform")
|| before[..before.len() - 7]
.chars()
.next_back()
.is_some_and(is_ident)
{
continue;
}
let rest = &text[from..];
let ident = rest.trim_start();
let len = ident.chars().take_while(|c| is_ident(*c)).count();
if &ident[..len] != name {
continue;
}
let after = ident[len..].trim_start();
if after.starts_with(';') {
return Some(text.len() - after.len() + 1);
}
}
None
}
/// The samplers a call passes for `f`'s `sampler2D` parameters, in order.
fn chosen_samplers(f: &SamplerFn, args: &[&str]) -> Vec<String> {
f.samplers
.iter()
.filter_map(|(n, _)| args.get(*n).map(|a| a.trim().to_owned()))
.collect()
}
/// One pass per function with `sampler2D` parameters, until none is left or one can't be
/// followed (a caller passes something that is no sampler's name).
fn specialize_sampler_params(original: &str) -> String {
let mut text = original.to_owned();
// Bytes scanned for calls so far, against [`SCAN_BUDGET`].
let scanned = std::cell::Cell::new(0usize);
let spent = || scanned.get() > SCAN_BUDGET;
for _ in 0..16 {
let functions = sampler_functions(&text);
// From the outside in: no call inside another function still to be copied.
let inside_another = |f: &SamplerFn, at: usize| {
functions
.iter()
.any(|g| g.name != f.name && g.body.is_some() && (g.start..g.end).contains(&at))
};
let spans_of = |f: &SamplerFn| -> Vec<(usize, usize)> {
functions
.iter()
.filter(|g| g.name == f.name)
.map(|g| (g.start, g.end))
.collect()
};
let Some(f) = functions.iter().find(|f| {
f.body.is_some()
&& !spent()
&& calls(&scanned, &text, &f.name, &spans_of(f))
.iter()
.all(|(at, ..)| !inside_another(f, *at))
}) else {
if spent() {
break;
}
// None left to copy.
return text;
};
let spans = spans_of(f);
let found = calls(&scanned, &text, &f.name, &spans);
// Each call's samplers must be plain names (a global's, or a later pass's parameter).
let mut variants: Vec<Vec<String>> = Vec::new();
let mut call_starts: Vec<usize> = Vec::new();
let mut followable = true;
for (at, _, args) in &found {
let chosen = chosen_samplers(f, args);
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
followable = false;
break;
}
call_starts.push(*at);
if !variants.contains(&chosen) {
variants.push(chosen);
}
if variants.len() > 64 {
followable = false;
break;
}
}
// Overloads (`tap(sampler2D, vec2)` and `tap(sampler2D, vec3)`) each keep their own
// body; a call picks its samplers by position, so they must sit at the same places.
let same_places = functions.iter().filter(|g| g.name == f.name).all(|g| {
g.samplers
.iter()
.map(|s| s.0)
.eq(f.samplers.iter().map(|s| s.0))
});
if !followable || !same_places || spent() {
break;
}
// A body may call another overload of itself with its own sampler (vanilla's
// `sampleNearest` calls the six-argument one): once its sampler parameters read a
// sampler, those calls name that copy too, and their sets join the variants.
let rewrite_own_calls = |body: &str| -> Option<(String, Vec<Vec<String>>)> {
let mut body = body.to_owned();
let mut found = Vec::new();
let mut starts: Vec<usize> = calls(&scanned, &body, &f.name, &[])
.iter()
.map(|c| c.0)
.collect();
starts.sort_unstable_by(|a, b| b.cmp(a));
for start in starts {
if spent() {
return None;
}
let (end, chosen, with) = {
let (_, end, args) = calls(&scanned, &body, &f.name, &[])
.into_iter()
.find(|c| c.0 == start)?;
let chosen = chosen_samplers(f, &args);
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let with = format!("{}_hz_{}({})", f.name, chosen.join("_"), rest.join(", "));
(end, chosen, with)
};
if chosen.len() != f.samplers.len() || !chosen.iter().all(|a| is_name(a)) {
return None;
}
found.push(chosen);
body.replace_range(start..end, &with);
}
Some((body, found))
};
let specialize = |g: &SamplerFn, chosen: &[String]| {
let body = read_samplers(g.body.as_deref().unwrap_or_default(), &g.samplers, chosen);
rewrite_own_calls(&body)
};
let same: Vec<&SamplerFn> = functions.iter().filter(|g| g.name == f.name).collect();
let mut next = 0;
while followable && next < variants.len() {
let chosen = variants[next].clone();
for g in same.iter().filter(|g| g.body.is_some()) {
match specialize(g, &chosen) {
Some((_, inner)) => {
for v in inner {
if !variants.contains(&v) {
variants.push(v);
}
}
}
None => followable = false,
}
}
next += 1;
followable &= variants.len() <= 64 && !spent();
}
if !followable {
break;
}
// One copy of an overload per set of samplers its callers pass, keeping its other
// parameters; a prototype becomes the copies' prototypes.
let signature = |g: &SamplerFn, chosen: &[String]| {
let params: Vec<&str> = g
.params
.iter()
.enumerate()
.filter(|(n, _)| !g.samplers.iter().any(|(s, _)| s == n))
.map(|(_, p)| p.as_str())
.collect();
format!(
"{} {}_hz_{}({})",
g.return_type,
g.name,
chosen.join("_"),
params.join(", ")
)
};
let copies_of = |g: &SamplerFn| -> String {
variants
.iter()
.map(|chosen| match &g.body {
Some(_) => {
let body = specialize(g, chosen).map(|(b, _)| b).unwrap_or_default();
format!("{} {body}\n", signature(g, chosen))
}
None => format!("{};\n", signature(g, chosen)),
})
.collect()
};
// Rebuild from the back: the calls, then the function's own spans (each prototype and
// definition becomes its copies). A call nested in another's arguments starts after it,
// so it is rewritten first, and the call around it is read again from the text as it is
// then. A copy reads its samplers by name, so definitions above their samplers'
// declaration (vanilla's `sample_lightmap.glsl` is included above `uniform sampler2D
// Sampler2;`) move after it, together and in their order, as long as no call comes first.
let first_call = call_starts.iter().copied().min().unwrap_or(usize::MAX);
let declared = variants
.iter()
.flatten()
.filter_map(|s| sampler_declaration_end(&text, s))
.max();
let mut edits: Vec<(usize, Option<(usize, String)>)> =
call_starts.into_iter().map(|at| (at, None)).collect();
let (mut moved, mut move_to) = (String::new(), None);
for g in &same {
match (
&g.body,
declared.filter(|&end| end > g.start && end <= first_call),
) {
(Some(_), Some(end)) => {
edits.push((g.start, Some((g.end, String::new()))));
moved.push_str(&copies_of(g));
move_to = Some(end);
}
_ => edits.push((g.start, Some((g.end, copies_of(g))))),
}
}
if let Some(end) = move_to {
edits.push((end, Some((end, format!("\n{moved}")))));
}
// Pack text is untrusted: edits that overlap, or off the text, leave it as it was.
let mut spans: Vec<(usize, usize)> = edits
.iter()
.filter_map(|(s, e)| e.as_ref().map(|(end, _)| (*s, *end)))
.collect();
spans.sort_unstable();
if spans.windows(2).any(|w| w[1].0 < w[0].1) {
break;
}
edits.sort_by_key(|(start, _)| std::cmp::Reverse(*start));
let mut sound = true;
for (start, edit) in edits {
let (end, with) = match edit {
Some(span) => span,
None => {
let found = calls(&scanned, &text, &f.name, &[])
.into_iter()
.find(|c| c.0 == start);
let Some((_, end, args)) = found else {
continue;
};
let rest: Vec<&str> = args
.iter()
.enumerate()
.filter(|(n, _)| !f.samplers.iter().any(|(s, _)| s == n))
.map(|(_, a)| a.trim())
.collect();
let copy = format!("{}_hz_{}", f.name, chosen_samplers(f, &args).join("_"));
(end, format!("{copy}({})", rest.join(", ")))
}
};
if start > end || text.get(start..end).is_none() || spent() {
sound = false;
break;
}
text.replace_range(start..end, &with);
}
if !sound {
break;
}
}
// One that couldn't be followed: the shader as written, never half rewritten.
original.to_owned()
}
/// Each array declared without a size that an initializer sizes: `T[] name = T[](a, b)` and
/// `T name[] = T[](a, b)` (or `T[2](...)`) are written with the size.
fn size_unsized_arrays(text: &str) -> String {
let mut text = text.to_owned();
let mut closing = closings(&text);
// Each array sized reads the text's brackets again: a few hundred are plenty.
let mut left = 256;
let mut from = 0;
while let Some(found) = text[from..].find('=') {
let eq = from + found;
from = eq + 1;
// `==`, `<=`, `>=`, `!=`, `+=` ... are no initializers.
let prev = text[..eq].chars().next_back();
let next = text[eq + 1..].chars().next();
if matches!(
prev,
Some('=' | '<' | '>' | '!' | '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^')
) || next == Some('=')
{
continue;
}
// The constructor: `T [ n? ] (`.
let rest = &text[eq + 1..];
let ty_start = eq + 1 + (rest.len() - rest.trim_start().len());
let ty_len = text[ty_start..]
.chars()
.take_while(|c| is_ident(*c))
.count();
if ty_len == 0 {
continue;
}
let after_ty = &text[ty_start + ty_len..];
let bracket = ty_start + ty_len + (after_ty.len() - after_ty.trim_start().len());
if !text[bracket..].starts_with('[') {
continue;
}
let Some(bracket_end) = closing[bracket] else {
continue;
};
let after_bracket = &text[bracket_end + 1..];
let paren = bracket_end + 1 + (after_bracket.len() - after_bracket.trim_start().len());
if !text[paren..].starts_with('(') {
continue;
}
let Some(paren_end) = closing[paren] else {
continue;
};
// The declarator: `name []` or `T [] name`, between the statement's start and `=`.
// (Looked for on the line, at most 256 bytes back, so a long line stays linear.)
let mut back = eq.saturating_sub(256);
while !text.is_char_boundary(back) {
back += 1;
}
let statement_start = text[back..eq]
.rfind([';', '{', '}', '\n'])
.map_or(back, |i| back + i + 1);
let declarator = &text[statement_start..eq];
let Some(empty) = declarator.rfind('[') else {
continue;
};
let inside = &declarator[empty + 1..];
let Some(close) = inside.find(']') else {
continue;
};
if !inside[..close].trim().is_empty() {
continue;
}
let size = split_top(&text[paren + 1..paren_end]).len().to_string();
// Sized in the constructor too, where it isn't.
if text[bracket + 1..bracket_end].trim().is_empty() {
text.replace_range(bracket + 1..bracket_end, &size);
}
let at = statement_start + empty + 1;
text.replace_range(at..at + close, &size);
// The declarator is before `=`, so `=` moved by what its size changed.
from = eq + 1 + size.len() - close;
closing = closings(&text);
left -= 1;
if left == 0 {
break;
}
}
text
}
#[cfg(test)]
mod tests;
@@ -1,291 +0,0 @@
//! The rewrites on GLSL a pack writes, with the text each gives written out by hand.
use super::*;
#[test]
fn a_sampler_parameter_becomes_a_copy_per_sampler_passed() {
let text = "vec4 blur(sampler2D tex, vec2 uv);\n\
vec4 blur(sampler2D tex, vec2 uv) { return texture(tex, uv) + texture(tex, uv * 2.0); }\n\
void main() { vec4 a = blur(Sampler0, uv); vec4 b = blur( Sampler1 , uv + 1.0); vec4 c = blur(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains(
"vec4 blur_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv) + texture(Sampler0, uv * 2.0); }"
));
assert!(out.contains("vec4 blur_hz_Sampler1(vec2 uv)"));
assert!(out.contains("blur_hz_Sampler0(uv)"));
assert!(out.contains("blur_hz_Sampler1(uv + 1.0)"));
// One copy per sampler, not per call (and its prototype, from the prototype above).
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv) {").count(), 1);
assert_eq!(out.matches("vec4 blur_hz_Sampler0(vec2 uv);").count(), 1);
}
#[test]
fn a_sampler_passed_on_through_another_function_is_followed() {
let text = "vec4 inner(sampler2D t, vec2 uv) { return texture(t, uv); }\n\
vec4 outer(vec2 uv, sampler2D s) { return inner(s, uv); }\n\
void main() { vec4 a = outer(uv, Sampler2); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(out.contains("outer_hz_Sampler2(uv)"), "{out}");
assert!(out.contains("inner_hz_Sampler2(uv)"), "{out}");
}
/// A call in another call's arguments is rewritten before the call around it, which is
/// then read again: neither edit lands on the other's old text.
#[test]
fn a_call_nested_in_another_calls_arguments_is_rewritten_inside_out() {
let text = "vec2 tap(sampler2D s, vec2 uv) { return texture(s, uv).xy; }\n\
void main() { vec2 a = tap(Sampler0, tap(Sampler0, uv));\n\
vec2 b = tap( Sampler1 , tap( Sampler0 , uv ) ); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D") && !out.contains("tap("), "{out}");
assert!(
out.contains("vec2 a = tap_hz_Sampler0(tap_hz_Sampler0(uv));"),
"{out}"
);
assert!(
out.contains("vec2 b = tap_hz_Sampler1(tap_hz_Sampler0(uv)); }"),
"{out}"
);
assert!(out.contains("vec2 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv).xy; }"));
assert!(out.contains("vec2 tap_hz_Sampler1(vec2 uv) { return texture(Sampler1, uv).xy; }"));
}
/// Overloads that differ past their sampler each keep their own body in their copies.
#[test]
fn overloads_with_a_sampler_each_keep_their_own_body() {
let text = "vec4 tap(sampler2D s, vec2 p) { return texture(s, p); }\n\
vec4 tap(sampler2D s, vec3 p) { return texture(s, p.xy / p.z); }\n\
void main() { vec4 a = tap(Sampler0, uv); vec4 b = tap(Sampler0, vec3(uv, 2.0)); }\n";
let out = specialize_sampler_params(text);
assert!(!out.contains("sampler2D"), "{out}");
assert!(
out.contains("vec4 tap_hz_Sampler0(vec2 p) { return texture(Sampler0, p); }"),
"{out}"
);
assert!(
out.contains("vec4 tap_hz_Sampler0(vec3 p) { return texture(Sampler0, p.xy / p.z); }"),
"{out}"
);
assert_eq!(out.matches("vec4 tap_hz_Sampler0(").count(), 2, "{out}");
assert!(out.contains("vec4 a = tap_hz_Sampler0(uv);"), "{out}");
assert!(
out.contains("vec4 b = tap_hz_Sampler0(vec3(uv, 2.0));"),
"{out}"
);
}
/// A copy reads its samplers by name, so it goes after a sampler declared below the helper
/// (vanilla's world `text.vsh` includes `sample_lightmap.glsl` above `Sampler2`).
#[test]
fn a_copy_goes_after_its_samplers_declaration() {
let text = "vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n\
uniform sampler2D Sampler2;\n\
void main() { vec4 c = tap(Sampler2, vec2(0.5)); }\n";
let out = specialize_sampler_params(text);
let declared = out
.find("uniform sampler2D Sampler2;")
.expect("declaration kept");
let copy = out
.find("vec4 tap_hz_Sampler2(vec2 uv)")
.expect("copy written");
let call = out
.find("tap_hz_Sampler2(vec2(0.5))")
.expect("call rewritten");
assert!(declared < copy && copy < call, "{out}");
assert_eq!(out.matches("tap_hz_Sampler2(vec2 uv)").count(), 1, "{out}");
assert!(!out.contains("tap(sampler2D"), "{out}");
}
/// A pack's text is untrusted: a sampler declared inside the helper itself (and text that
/// isn't ASCII) gives edits that would overlap, so the shader is left as it is for the
/// translator to refuse: never a panic, never a half-rewritten shader.
#[test]
fn a_sampler_declared_inside_the_helper_never_panics() {
let text = "vec4 tap(sampler2D s, vec2 uv) { é uniform sampler2D Sampler0; x return texture(s, uv); ééé }\n\
void main() { fragColor = tap(Sampler0, vec2(0.0)); }\n";
let out = specialize_sampler_params(text);
assert_eq!(out, text);
}
/// Vanilla's `texture_sampling.glsl` shape: one overload calls the other with its own
/// sampler. That call is rewritten too, and both copies come after the sampler's declaration
/// in their own order, the called one first.
#[test]
fn an_overload_passing_its_sampler_to_another_is_followed() {
let text = "vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize) { return texture(source, uv + du + dv); }\n\
vec4 sampleNearest(sampler2D source, vec2 uv, vec2 pixelSize) { return sampleNearest(source, uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }\n\
uniform sampler2D Sampler0;\n\
void main() { vec4 c = sampleNearest(Sampler0, uv, px); }\n";
let out = specialize_sampler_params(text);
assert!(
!out.contains("sampleNearest(") && !out.contains("sampler2D source"),
"{out}"
);
let declared = out
.find("uniform sampler2D Sampler0;")
.expect("declaration");
let six = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize, vec2 du, vec2 dv, vec2 texelSize)")
.expect("six-argument copy");
let three = out
.find("vec4 sampleNearest_hz_Sampler0(vec2 uv, vec2 pixelSize) { return sampleNearest_hz_Sampler0(uv, pixelSize, dFdx(uv), dFdy(uv), pixelSize); }")
.expect("three-argument copy calling the six-argument one");
let call = out
.find("vec4 c = sampleNearest_hz_Sampler0(uv, px);")
.expect("call");
assert!(declared < six && six < three && three < call, "{out}");
}
/// A prototype above `main` with the definition below it becomes the copies' prototypes, so
/// the call still has a declaration above it.
#[test]
fn a_prototype_becomes_the_copies_prototypes() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D s, vec2 uv);\n\
void main() { vec4 c = tap(Sampler0, uv); }\n\
vec4 tap(sampler2D s, vec2 uv) { return texture(s, uv); }\n";
let out = specialize_sampler_params(text);
let prototype = out
.find("vec4 tap_hz_Sampler0(vec2 uv);")
.expect("prototype copy");
let call = out.find("tap_hz_Sampler0(uv); }").expect("call");
let definition = out
.find("vec4 tap_hz_Sampler0(vec2 uv) { return texture(Sampler0, uv); }")
.expect("definition copy");
assert!(prototype < call && call < definition, "{out}");
}
/// Parameters named like the samplers they are passed, swapped, still read their own; and a
/// parameter `s` leaves the swizzle `c.s` alone.
#[test]
fn sampler_parameters_are_replaced_at_once_and_not_as_members() {
let text = "uniform sampler2D Sampler0;\nuniform sampler2D Sampler1;\n\
vec4 mix2(sampler2D Sampler0, sampler2D Sampler1, vec2 uv) { return texture(Sampler0, uv) - texture(Sampler1, uv); }\n\
vec4 swz(sampler2D s, vec2 uv) { vec4 c = texture(s, uv); return vec4(c.s, c.t, 0.0, 1.0); }\n\
void main() { vec4 a = mix2(Sampler1, Sampler0, uv) + swz(Sampler0, uv); }\n";
let out = specialize_sampler_params(text);
assert!(
out.contains("{ return texture(Sampler1, uv) - texture(Sampler0, uv); }"),
"{out}"
);
assert!(
out.contains("{ vec4 c = texture(Sampler0, uv); return vec4(c.s, c.t, 0.0, 1.0); }"),
"{out}"
);
}
#[test]
fn a_parameter_matched_twice_in_one_identifier_stays_whole() {
assert_eq!(replace_ident("c.ss + ss * s", "s", "t"), "c.ss + ss * t");
assert_eq!(replace_ident("brightness(s)", "s", "t"), "brightness(t)");
}
/// `fsh` translated beside a vertex shader that passes its position on.
fn translate(fsh: &str) -> Result<crate::core_shader::CoreProgram, String> {
let vsh = "#version 330\nin vec3 Position;\nout vec2 texCoord0;\n\
void main() {\n gl_Position = vec4(Position, 1.0);\n texCoord0 = Position.xy;\n}\n";
let glue = crate::core_shader::Glue {
inputs: &[("Position", "vec3")],
prelude: "layout(location = 0) in vec3 hz_position;",
setup: "Position = hz_position;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &glue)
}
/// #9386 end to end: naga refuses a helper taking the sampler it reads, as GLSL vanilla's GL
/// compiler takes it; the pack's shader translates, the helper a copy reading `Sampler0`.
#[test]
fn a_helper_taking_its_sampler_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
vec4 tap(sampler2D tex, vec2 at, float w) { return texture(tex, at) * w; }\n\
void main() {\n fragColor = tap(Sampler0, texCoord0, 0.5) + tap(Sampler0, texCoord0 + 0.01, 0.5);\n}\n";
let program = translate(fsh).unwrap();
let names: Vec<_> = program
.fragment
.functions
.iter()
.filter_map(|(_, f)| f.name.as_deref())
.collect();
assert!(names.contains(&"tap_hz_Sampler0"), "{names:?}");
assert!(!names.contains(&"tap"), "{names:?}");
}
/// A sampler parameter with no name used to hand `replace_ident` an empty name to replace,
/// which it found at the same place forever, growing the text until the game ran out of memory.
#[test]
fn a_sampler_parameter_without_a_name_is_left_as_it_was() {
let text = "uniform sampler2D Sampler0;\n\
vec4 tap(sampler2D ;, vec2 uv) { return texture(Sampler0, uv); }\n\
void main() { vec4 c = tap(Sampler0, uv); }\n";
assert_eq!(fixups(text), text);
assert_eq!(replace_ident("abc", "", "x"), "abc");
}
/// Each call rewritten reads the whole text again: thousands of them stop at the scan budget,
/// the shader left as it was (never half rewritten) instead of freezing the load.
#[test]
fn thousands_of_calls_are_left_as_they_were() {
let shader = format!(
"uniform sampler2D S;\nvec4 t(sampler2D s) {{ return texture(s, vec2(0.0)); }}\n\
void main() {{ vec4 c; {}}}\n",
"c = t(S);".repeat(5000)
);
assert_eq!(fixups(&shader), shader);
// A few of them are rewritten.
let few = shader.replace(&"c = t(S);".repeat(5000), &"c = t(S);".repeat(3));
assert_eq!(
fixups(&few).matches("c = t_hz_S(").count(),
3,
"{}",
fixups(&few)
);
}
#[test]
fn unsized_arrays_take_their_initializers_size() {
let text = "const float[] w = float[](0.25, 0.5, 0.25);\n\
const vec2 offsets[] = vec2[](vec2(1.0, 0.0), vec2(0.0, 1.0));\n\
float k[] = float[4](1.0, 2.0, 3.0, 4.0);\n\
bool same = a == b;\n";
let out = size_unsized_arrays(text);
assert!(
out.contains("const float[3] w = float[3](0.25, 0.5, 0.25);"),
"{out}"
);
assert!(
out.contains("const vec2 offsets[2] = vec2[2](vec2(1.0, 0.0), vec2(0.0, 1.0));"),
"{out}"
);
assert!(
out.contains("float k[4] = float[4](1.0, 2.0, 3.0, 4.0);"),
"{out}"
);
assert!(out.contains("bool same = a == b;"));
}
#[test]
fn a_wide_character_after_a_sized_array_never_panics() {
// Ten values make the size two digits, which moves the `=` two bytes right: the scan
// went on from where it used to be, inside the `é`.
let text = "float w[]\u{e9}= float[](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n";
assert_eq!(
size_unsized_arrays(text),
"float w[10]\u{e9}= float[10](0.,1.,2.,3.,4.,5.,6.,7.,8.,9.);\n"
);
}
/// #9386 end to end: naga refuses an array sized by its initializer only.
#[test]
fn an_array_sized_by_its_initializer_translates() {
let fsh = "#version 330\nuniform sampler2D Sampler0;\nin vec2 texCoord0;\nout vec4 fragColor;\n\
const float[] weights = float[](0.25, 0.5, 0.25);\n\
void main() {\n vec4 sum = vec4(0.0);\n\
for (int i = 0; i < 3; i++) {\n sum += texture(Sampler0, texCoord0 + float(i) * 0.01) * weights[i];\n }\n\
fragColor = sum;\n}\n";
let program = translate(fsh);
assert!(program.is_ok(), "{:?}", program.err());
}
+6 -19
View File
@@ -439,33 +439,20 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack,
stack: String::new(),
thread: thread_name(side),
side,
details,
details: system_details(side),
log: crate::logging::recent_lines(),
},
)
@@ -1,241 +0,0 @@
//! Dynamic Resolution: the world's resolution scale follows the GPU's frame time toward a target
//! frame rate, between the Resolution Scale setting (the floor) and 100%.
//!
//! The setting is the lowest scale the player accepts; where the GPU has room for more, the
//! scale rises toward 100% a step at a time, and falls back toward the floor where frames take
//! longer than the target allows. A verdict is the median GPU time of a window of frames, which
//! a few slow frames (a chunk load) don't move, and a step up is taken only when the frame would
//! still fit if its cost grew with the pixels: most of a frame doesn't, so it errs toward
//! staying.
//!
//! Each change has a price: new render targets, and history that starts over (TAA, and a
//! shader pack's own: its denoised light, reflections and accumulation). So a shader pack gets
//! the gentle pace (long windows, big steps, a long rest after each change): walking from a
//! heavy scene to a light one changes the scale once, not every few frames. Without a pack the
//! quick pace follows the scene within a second.
//!
//! Nothing here draws or reads the GPU: the caller feeds it the frames and applies the scale it
//! answers.
/// How fast the scale follows the frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pace {
/// A shader pack: its history is expensive to start over.
Gentle,
/// Horizon's own renderer.
Quick,
}
impl Pace {
/// Milliseconds of frames in one verdict.
fn window_ms(self) -> f64 {
match self {
Pace::Gentle => 3_000.0,
Pace::Quick => 500.0,
}
}
/// Milliseconds of frames after a change before the next verdict counts, while the new
/// targets and the restarted history settle.
fn settle_ms(self) -> f64 {
match self {
Pace::Gentle => 8_000.0,
Pace::Quick => 1_000.0,
}
}
/// Percentage points per change.
fn step(self) -> u32 {
match self {
Pace::Gentle => 10,
Pace::Quick => 5,
}
}
}
/// The frame is too slow when its GPU time passes the target's frame time by this share.
const DOWN_ABOVE: f32 = 1.05;
/// A step up is taken when the frame, its cost grown with the pixels, stays under this share.
const UP_BELOW: f32 = 0.95;
/// Timed frames a window needs for a verdict (a GPU without timestamps gives none).
const MIN_TIMED: usize = 8;
/// The scale in use and what it has measured since the last verdict.
#[derive(Clone, Debug, Default)]
pub struct DynamicResolution {
gpu: Vec<f32>,
window_ms: f64,
since_change_ms: f64,
/// The scale in use, in percent; `None` until the first change (the floor).
scale: Option<u32>,
}
impl DynamicResolution {
pub fn new() -> Self {
Self::default()
}
/// Back to the floor, with nothing measured (turned off, or the world changed).
pub fn reset(&mut self) {
*self = Self::new();
}
/// The scale in use, in percent, for a Resolution Scale of `floor`: never below it, never
/// above 100% unless the floor itself is (then it is the floor).
pub fn scale(&self, floor: u32) -> u32 {
self.scale.unwrap_or(floor).clamp(floor, floor.max(100))
}
/// One frame: its GPU time where its timestamps have arrived, and its whole time, in
/// milliseconds. Answers the new scale, in percent, when this frame completed a window that
/// changed it.
pub fn observe(
&mut self,
gpu_ms: Option<f32>,
frame_ms: f32,
floor: u32,
target_fps: u32,
pace: Pace,
) -> Option<u32> {
self.window_ms += f64::from(frame_ms);
self.since_change_ms += f64::from(frame_ms);
if let Some(gpu) = gpu_ms {
self.gpu.push(gpu);
}
// A window lasts its time and holds enough timed frames: a slow scene (or timestamps that
// arrive with gaps) keeps gathering past the time rather than starting over with too few,
// which would never judge the scenes that most need a lower scale. A GPU that can't be
// timed gathers nothing and so leaves the scale alone.
if self.window_ms < pace.window_ms() || self.gpu.len() < MIN_TIMED {
return None;
}
self.window_ms = 0.0;
let timed = std::mem::take(&mut self.gpu);
// At or above 100% there is nothing to vary; a change still settling isn't judged yet.
if floor >= 100 || self.since_change_ms < pace.settle_ms() {
return None;
}
let gpu = median(timed);
let target = 1000.0 / target_fps.max(1) as f32;
let now = self.scale(floor);
let next = if gpu > target * DOWN_ABOVE && now > floor {
now.saturating_sub(pace.step()).max(floor)
} else if now < 100 {
let up = (now + pace.step()).min(100);
let grown = (up as f32 / now as f32).powi(2);
if gpu * grown < target * UP_BELOW {
up
} else {
now
}
} else {
now
};
if next == now {
return None;
}
self.scale = Some(next);
self.since_change_ms = 0.0;
Some(next)
}
}
fn median(mut values: Vec<f32>) -> f32 {
values.sort_by(f32::total_cmp);
values[values.len() / 2]
}
#[cfg(test)]
mod tests {
use super::*;
/// `ms` milliseconds of frames, each `gpu` long on the GPU and `frame` long in all; the last
/// answer.
fn run(
d: &mut DynamicResolution,
ms: f64,
gpu: f32,
frame: f32,
floor: u32,
pace: Pace,
) -> Option<u32> {
let mut answer = None;
let mut t = 0.0;
while t < ms {
if let Some(scale) = d.observe(Some(gpu), frame, floor, 60, pace) {
answer = Some(scale);
}
t += f64::from(frame);
}
answer
}
#[test]
fn it_starts_at_the_floor_and_climbs_while_there_is_room() {
let mut d = DynamicResolution::new();
assert_eq!(d.scale(70), 70);
// 8 ms of a 16.7 ms budget: room to grow, one step per window and settle.
assert_eq!(run(&mut d, 1_200.0, 8.0, 8.0, 70, Pace::Quick), Some(75));
assert_eq!(run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick), Some(100));
assert_eq!(d.scale(70), 100);
}
#[test]
fn a_slow_frame_steps_down_but_never_below_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 25 ms against 16.7: down a step at a time, to the floor and no further.
run(&mut d, 60_000.0, 25.0, 25.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_slow_scene_still_steps_down() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 100);
// 10 FPS: five frames in a 500 ms window, fewer than a verdict needs; the window keeps
// gathering until it has them.
run(&mut d, 60_000.0, 100.0, 100.0, 70, Pace::Quick);
assert_eq!(d.scale(70), 70);
}
#[test]
fn a_frame_near_the_target_stays_put() {
let mut d = DynamicResolution::new();
// 15 ms: under the target, but a step up (75 -> 80%) would make it 17 ms.
assert_eq!(run(&mut d, 60_000.0, 15.0, 16.0, 75, Pace::Quick), None);
assert_eq!(d.scale(75), 75);
}
#[test]
fn the_gentle_pace_takes_big_steps_and_long_rests() {
let mut d = DynamicResolution::new();
// The first window (3 s) can't count yet: a change rests 8 s, and the first counts
// from the start.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(run(&mut d, 3_000.0, 8.0, 8.0, 70, Pace::Gentle), Some(80));
// Then nothing for the 8 s rest.
assert_eq!(run(&mut d, 7_000.0, 8.0, 8.0, 70, Pace::Gentle), None);
assert_eq!(d.scale(70), 80);
}
#[test]
fn nothing_changes_at_or_above_100_or_without_gpu_times() {
let mut d = DynamicResolution::new();
assert_eq!(run(&mut d, 30_000.0, 30.0, 30.0, 100, Pace::Quick), None);
assert_eq!(d.scale(120), 120);
let mut d = DynamicResolution::new();
for _ in 0..1_000 {
assert_eq!(d.observe(None, 8.0, 70, 60, Pace::Quick), None);
}
assert_eq!(d.scale(70), 70);
}
#[test]
fn reset_goes_back_to_the_floor() {
let mut d = DynamicResolution::new();
run(&mut d, 30_000.0, 8.0, 8.0, 70, Pace::Quick);
d.reset();
assert_eq!(d.scale(70), 70);
}
}
+8 -15
View File
@@ -155,8 +155,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat, and the
// entitySolid decorated pot).
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
@@ -294,15 +293,10 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
}
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
// product is in gamma, so under light it is (2c)^2.2: they give half of that.
return vec4(pow(2.0 * max(color.rgb, vec3(0.0)), vec3(2.2)) * 0.5, color.a);
}
return frame_stored(color);
return color;
}
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return frame_stored(entity_color(in, front));
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
@@ -360,7 +354,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return frame_stored(shadow_color(in));
return shadow_color(in);
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
@@ -453,7 +447,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return frame_stored(color);
return color;
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
@@ -474,8 +468,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
if !full { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
@@ -498,7 +491,7 @@ fn energy_swirl_fog(in: Out) -> f32 {
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return frame_stored(vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a));
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -627,7 +620,7 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return frame_stored(weather_color(in));
return weather_color(in);
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
+215 -9
View File
@@ -166,6 +166,24 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -220,6 +238,13 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -229,6 +254,11 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -323,6 +353,15 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -333,7 +372,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -382,6 +421,16 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -567,6 +616,13 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -574,10 +630,8 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -598,7 +652,19 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
]
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -624,6 +690,12 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -650,6 +722,12 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -685,6 +763,12 @@ impl Item {
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -699,7 +783,8 @@ impl Item {
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
return None;
}
})
@@ -757,6 +842,24 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -783,6 +886,12 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -808,6 +917,10 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -817,14 +930,30 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -846,6 +975,16 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -864,6 +1003,73 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
+19 -2
View File
@@ -206,13 +206,14 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and the sections' rows.
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
shell::stamp_of(&(&s.extras, &s.mods, added))
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -320,6 +321,22 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
+261 -89
View File
@@ -47,10 +47,6 @@ fn terrain_shader() -> String {
include_str!("hdr_light.wgsl").to_owned() + include_str!("terrain.wgsl")
}
fn border_shader() -> String {
include_str!("hdr_light.wgsl").to_owned() + include_str!("border.wgsl")
}
/// The static block-entity vertex entry point shares entity shading but lives
/// in its own source file and pipeline.
fn static_entity_shader() -> String {
@@ -195,7 +191,6 @@ pub mod capture;
pub mod compositor;
pub(crate) mod core_lines;
pub(crate) mod core_sky;
pub(crate) mod core_text;
mod cull;
mod dx12_compiler;
mod end_sky;
@@ -217,12 +212,7 @@ pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
mod frame_policy;
pub mod lightning;
mod linear_blend;
#[cfg(test)]
use frame_policy::frame_format;
use frame_policy::{frame_configuration, horizon_tone_curve, overlay_format};
mod lod;
mod native_material;
mod native_pack;
@@ -1137,8 +1127,6 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The terrain's acceleration structures, with ray tracing on (`ray_scene`).
ray_scene: Option<surface_setup::ray_query::ray_scene::RayScene>,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
@@ -1178,7 +1166,6 @@ pub struct Renderer {
lines_secondary: wgpu::RenderPipeline,
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
core_lines: Option<core_lines::CoreLines>,
core_text: Option<core_text::CoreText>,
/// The packs' `core/sky` and `core/stars`, drawn in place of the native sky and stars.
core_sky: Option<core_sky::CoreSky>,
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
@@ -2187,6 +2174,74 @@ fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
/// survive from the world to the tonemap and the HDR pass.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
/// on a layer of its own.
fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
fn overlay_format(
frame: wgpu::TextureFormat,
layered: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if frame == LINEAR_FRAME && !layered {
swapchain.remove_srgb_suffix()
} else {
frame
}
}
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
layered && world && !pack_tonemap
}
/// Opens a device on the first of `backend_order`'s backends that gives one.
/// Offscreen rendering needs no surface, so it can use a GPU the window's
/// display cannot present from (a headless compositor).
@@ -2856,7 +2911,6 @@ impl Renderer {
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
(frame.format, frame.hud_layer, frame.overlay_format);
let linear_light = frame.linear_light;
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -3012,10 +3066,9 @@ impl Renderer {
blend: Option<wgpu::BlendState>,
hud: Option<bool>| {
let mut constants = constants.to_vec();
// The debug crosshair draws with the HUD, in gamma.
constants.extend(linear_blend::frame_constants(
frame_format,
linear_light && hud.is_none(),
constants.push((
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
));
let desc = wgpu::RenderPipelineDescriptor {
label: Some(label),
@@ -3828,12 +3881,8 @@ impl Renderer {
},
)],
});
let weather = weather::Weather::new(
&device,
(frame_format, linear_light),
&entity_bindings,
&entity_shader,
);
let weather =
weather::Weather::new(&device, frame_format, &entity_bindings, &entity_shader);
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Atlas"),
..Default::default()
@@ -3999,7 +4048,6 @@ impl Renderer {
];
#[allow(clippy::too_many_arguments)]
let entity_pipeline_in = |format,
linear,
label,
cull_mode,
depth_write,
@@ -4038,7 +4086,10 @@ impl Renderer {
module: &entity_shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear),
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4056,7 +4107,6 @@ impl Renderer {
|label, cull_mode, depth_write, compare, bias, blend, clouds, fragment, write_mask| {
entity_pipeline_in(
frame_format,
linear_light,
label,
cull_mode,
depth_write,
@@ -4196,12 +4246,8 @@ impl Renderer {
"fs_entity_unmarked",
wgpu::ColorWrites::ALL,
);
let world_text_pipeline = world_text::pipeline(
&device,
Some(&entity_layout),
&entity_shader,
(frame_format, linear_light),
);
let world_text_pipeline =
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
// ground it is stopped by, #5950). Terrain and entities are separate draw
@@ -4224,12 +4270,10 @@ impl Renderer {
alpha_blend,
false,
);
// The hand as `entity_pipeline` draws it, in the overlays' format and gamma: it draws
// after a 0.2 pack's tonemap.
let overlay_hand = (overlay_format != frame_format || linear_light).then(|| {
// The hand as `entity_pipeline` draws it, in the overlays' format.
let overlay_hand = (overlay_format != frame_format).then(|| {
entity_pipeline_in(
overlay_format,
false,
"First-person hand",
None,
true,
@@ -4263,10 +4307,7 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &static_entity_shader,
entry_point: Some("fs_entity_unmarked"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
// fs_entity's discard handles binary cutout alpha. Any
@@ -4373,7 +4414,10 @@ impl Renderer {
module: &entity_shader,
entry_point: Some("fs_celestial"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4468,7 +4512,10 @@ impl Renderer {
module: &entity_shader,
entry_point: Some("fs_sky_celestial"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(frame_format == LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -4607,7 +4654,7 @@ impl Renderer {
});
let border_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon world border"),
source: wgpu::ShaderSource::Wgsl(border_shader().into()),
source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()),
});
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
// RenderPipelines.WORLD_BORDER: BlendFunction.OVERLAY, no culling,
@@ -4644,10 +4691,7 @@ impl Renderer {
fragment: Some(wgpu::FragmentState {
module: &border_shader,
entry_point: Some("fs_border"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &linear_blend::frame_constants(frame_format, linear_light),
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: frame_format,
blend: Some(wgpu::BlendState {
@@ -4873,7 +4917,6 @@ impl Renderer {
entity: entity_bindings.clone(),
static_entity: static_entity_bindings.clone(),
format: frame_format,
linear: linear_light,
sprite_constants,
hdr_output: hdr_output.is_some(),
});
@@ -4884,13 +4927,7 @@ impl Renderer {
log::info!(
"Horizon: solid terrain through a visibility buffer (HORIZON_VISBUFFER)"
);
visbuffer::Visbuffer::new(
&device,
&bindings,
f,
(frame_format, linear_light),
&sprite_constants,
)
visbuffer::Visbuffer::new(&device, &bindings, f, frame_format, &sprite_constants)
});
let multi_draw = device
.features()
@@ -4971,8 +5008,8 @@ impl Renderer {
let world_post = world_post::Post::new(&device, frame_format);
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, (frame_format, linear_light));
let custom_sky = custom_sky::CustomSky::new(&device, (frame_format, linear_light));
let stars = stars::Stars::new(&device, frame_format);
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
@@ -5000,8 +5037,7 @@ impl Renderer {
on_off(gpu_timer.is_some()),
));
}
let world_primitives =
world_primitives::WorldPrimitives::new(&device, (frame_format, linear_light));
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
@@ -5025,7 +5061,6 @@ impl Renderer {
last_wait: std::time::Duration::ZERO,
last_encode: std::time::Duration::ZERO,
window,
ray_scene: surface_setup::ray_query::ray_scene::RayScene::new(&device),
device,
reuse,
queue: submit::Queue::new(queue, submit::wanted()),
@@ -5060,7 +5095,6 @@ impl Renderer {
lines,
lines_secondary,
core_lines: None,
core_text: None,
core_sky: None,
debug_lines_opaque,
debug_lines,
@@ -5408,8 +5442,6 @@ impl Renderer {
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
let format = self.output.frame.format;
// A pack's GLSL writes vanilla's gamma: a frame of linear light keeps the built-in one.
let program = program.filter(|_| !self.output.frame.linear_light);
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
}
@@ -5420,9 +5452,6 @@ impl Renderer {
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
) {
// As `set_lines_shader`: not on a frame of linear light.
let keep = !self.output.frame.linear_light;
let (disc, stars) = (disc.filter(|_| keep), stars.filter(|_| keep));
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
@@ -5448,7 +5477,17 @@ impl Renderer {
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
surface_setup::ray_query::quad_indices(device, quads)
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
let b = q * 4;
[b, b + 1, b + 2, b, b + 2, b + 3]
})
.collect();
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Quad indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
})
}
fn offscreen_target(
device: &wgpu::Device,
@@ -5927,7 +5966,6 @@ impl Renderer {
section: Option<SectionGpu>,
fading: Option<Instant>,
) {
self.ray_mark(key);
let section = section.map(|section| SectionGpu { fading, ..section });
self.set_detail(key, section.is_some() || mesh.empty);
if let Some(mut section) = section {
@@ -6616,7 +6654,6 @@ impl Renderer {
self.newest_lod_fade = None;
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
self.ray_clear();
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
self.hold.clear();
self.lods.clear();
@@ -7474,7 +7511,6 @@ impl Renderer {
flat: self.flat.as_ref().map(|f| &f.draw_layout),
sprites: self.terrain_sprites,
format: self.output.frame.format,
linear: self.output.frame.linear_light,
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
@@ -8846,7 +8882,6 @@ impl Renderer {
mapped_at_creation: false,
});
}
self.ray_prepare(&mut encoder, camera, frame.title);
self.origins.clear();
let (sections, retiring) = (&self.sections, &self.lod_retiring);
laps.lap("r_cameras");
@@ -9393,8 +9428,6 @@ impl Renderer {
&self.entity_atlas,
)
};
let pack = self.pack_blocks(frame, fog, globals.fog_ends);
self.write_core_text(&pack, views.primary(), pose, jitter, fog_color);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
@@ -9428,14 +9461,20 @@ impl Renderer {
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(lines));
if let Some(core) = &self.core_lines {
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
};
let [sky_end, clouds_end, ..] = globals.fog_ends;
let data = core_lines::uniforms(
pack.globals,
block.bytes(size, frame.post_effects.menu_blur_radius as i32),
views.primary(),
pose,
jitter,
globals.outline_params[3],
fog_color,
pack.fog,
[fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end],
);
self.uploads.buffer(&mut encoder, core.uniform(), 0, &data);
}
@@ -9445,7 +9484,14 @@ impl Renderer {
if let Some(core) = &self.core_sky
&& !frame.title
{
let (block, fog) = (pack.globals, pack.fog);
let size = [self.config.width, self.config.height];
let block = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
}
.bytes(size, frame.post_effects.menu_blur_radius as i32);
let [sky_end, clouds_end, ..] = globals.fog_ends;
let fog = [fog[0], fog[1], fog[2], fog[3], sky_end, clouds_end];
let view = views.primary();
// Far enough for the disc's rim, 512 out: the sky draws without a depth test.
let fov = (2.0 * view.tan_half_fov.atan()).to_degrees();
@@ -10165,7 +10211,6 @@ impl Renderer {
a: 1.0,
}
};
let clear = linear_blend::stored_clear(clear, self.output.frame.linear_light);
let sky_visible = sky_open(frame);
laps.lap("r_world_pass");
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
@@ -11004,7 +11049,7 @@ impl Renderer {
}
if text_quads > 0 && !oit_on {
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
self.bind_world_text(&mut pass, warped.is_none(), false);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
@@ -11562,7 +11607,7 @@ impl Renderer {
// LevelRenderer.executeSeeThrough, after the rest of the world.
if see_through_quads > 0 {
pass.set_pipeline(&self.see_through_pipeline);
self.bind_world_text(&mut pass, true, true);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..see_through_quads * 6,
@@ -12916,11 +12961,143 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
planes.map(|p| p / p.truncate().length())
}
#[cfg(test)]
mod frame_policy_tests;
#[cfg(test)]
mod tests {
#[test]
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let float_hdr = matches!(pack, "none" | "0.2");
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && float_hdr,
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
// A 0.2 pack keeps its float frame, without the layer: SDR.
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, true),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
assert_eq!(
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
// Everything else draws its overlays on its frame: the layered frame's light, the
// 16-bit frame, the 8-bit frame.
for (frame, layered) in [
(LINEAR_FRAME, true),
(WIDE_FRAME, false),
(Bgra8Unorm, false),
] {
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
}
}
#[test]
fn one_tone_curve_runs_on_the_layered_world() {
use super::horizon_tone_curve;
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
assert!(horizon_tone_curve(true, true, false));
// A pack's `[tonemap]` mapped it already; never both.
assert!(!horizon_tone_curve(true, true, true));
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
assert!(!horizon_tone_curve(true, false, false));
assert!(!horizon_tone_curve(false, true, false));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
#[test]
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
use super::*;
@@ -13693,15 +13870,10 @@ mod tests {
// The shared maths: it needs the file it is appended to. GPU culling's
// occlusion test is appended to gpu_culling.wgsl, on Vulkan alone.
continue;
} else if name == "rt_unpack.wgsl" {
// Appended to terrain.wgsl (`ray_scene`'s `Unpacker`), on ray tracing devices.
continue;
} else if name == "terrain.wgsl"
|| name == "entity.wgsl"
|| name == "stars.wgsl"
|| name == "custom_sky.wgsl"
|| name == "border.wgsl"
|| name == "world_primitives.wgsl"
{
let base = include_str!("hdr_light.wgsl").to_owned() + &text;
sources.push((path, crate::overlay::wgsl_constants() + &base));
@@ -13719,7 +13891,7 @@ mod tests {
// Appended to terrain.wgsl (gfx/visbuffer.rs).
sources.push((path, super::terrain_shader() + &text));
} else if name == "oit_border.wgsl" {
let base = super::border_shader() + include_str!("oit.wgsl");
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
@@ -136,8 +136,6 @@ impl Arena {
} else {
wgpu::BufferUsages::VERTEX
};
// BLAS builds read sections' vertices here (`ray_scene`).
let usage = usage | super::surface_setup::ray_query::blas_input(device);
Self::with_usage(device, stride, capacity, usage, PAGE_BYTES)
}
/// Sorted translucent indices; `storage` when compute passes read them.
@@ -1,315 +0,0 @@
//! A resource pack's `core/text` on world text, as vanilla 26.3's `TEXT_POLYGON_OFFSET` and
//! `TEXT_SEE_THROUGH` draw text displays, name tags and their backgrounds.
use crate::core_shader::{CoreProgram, Glue};
use std::sync::Arc;
/// The entity vertices' light bytes are sky then block; vanilla's `UV2` is block then sky.
pub(crate) const WORLD_GLUE: Glue = Glue {
inputs: &[
("Position", "vec3"),
("Color", "vec4"),
("UV0", "vec2"),
("UV2", "ivec2"),
],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;\n\
layout(location = 4) in uvec4 hz_light;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv; UV2 = ivec2(hz_light.y, hz_light.x);",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
pub(crate) const SEE_THROUGH_GLUE: Glue = Glue {
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
prelude: "layout(location = 0) in vec3 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 3) in vec4 hz_color;",
setup: "Position = hz_pos; Color = hz_color; UV0 = hz_uv;",
finish: "",
vertex_id: "int(gl_VertexIndex)",
};
#[derive(Default)]
pub struct TextShaders {
pub gui: Option<Arc<CoreProgram>>,
pub world: Option<Arc<CoreProgram>>,
pub see_through: Option<Arc<CoreProgram>>,
}
#[derive(Clone, Copy)]
pub(super) struct PackBlocks {
pub(super) globals: [u8; 48],
pub(super) fog: [f32; 6],
}
impl super::Renderer {
pub fn set_world_text_shaders(&mut self, shaders: &TextShaders) {
self.core_text = CoreText::new(
&self.device,
self.output.frame.format,
shaders.world.as_deref(),
shaders.see_through.as_deref(),
&self.entity_atlas,
(&self.lightmap_view, &self.lightmap_sampler),
);
}
pub(super) fn pack_blocks(
&self,
frame: &super::Frame,
fog: [f32; 4],
ends: [f32; 4],
) -> PackBlocks {
let globals = crate::post_chain::Globals {
glint_alpha: frame.glint_alpha,
..frame.post_effects.globals
};
let size = [self.config.width, self.config.height];
PackBlocks {
globals: globals.bytes(size, frame.post_effects.menu_blur_radius as i32),
fog: [fog[0], fog[1], fog[2], fog[3], ends[0], ends[1]],
}
}
pub(super) fn write_core_text(
&self,
pack: &PackBlocks,
view: &super::View,
pose: glam::Mat4,
jitter: glam::Mat4,
fog_color: [f32; 4],
) {
let Some(core) = &self.core_text else {
return;
};
let data =
super::core_lines::uniforms(pack.globals, view, pose, jitter, 1.0, fog_color, pack.fog);
self.queue.write_buffer(&core.uniform, 0, &data);
}
pub(super) fn bind_world_text(
&self,
pass: &mut wgpu::RenderPass<'_>,
allowed: bool,
see_through: bool,
) {
let pack = self
.core_text
.as_ref()
.filter(|_| allowed)
.and_then(|core| {
let pipeline = if see_through {
core.see_through.as_ref()
} else {
core.polygon_offset.as_ref()
};
Some((pipeline?, &core.group))
});
match pack {
Some((pipeline, group)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
}
None => pass.set_bind_group(0, &self.entity_group, &[]),
}
}
}
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
pub(super) struct CoreText {
pub(super) polygon_offset: Option<wgpu::RenderPipeline>,
pub(super) see_through: Option<wgpu::RenderPipeline>,
pub(super) group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl CoreText {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
world: Option<&CoreProgram>,
see_through: Option<&CoreProgram>,
atlas: &wgpu::TextureView,
lightmap: (&wgpu::TextureView, &wgpu::Sampler),
) -> Option<Self> {
if world.is_none() && see_through.is_none() {
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let buffer = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
},
count: None,
};
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let sampler = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack world text"),
entries: &[
buffer(0),
buffer(1),
buffer(2),
buffer(3),
texture(4),
sampler(5),
texture(8),
sampler(9),
],
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack world text uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let glyphs = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Resource pack world text glyphs"),
..Default::default()
});
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
resources.extend([
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&glyphs),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(lightmap.0),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::Sampler(lightmap.1),
},
]);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack world text"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack world text"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::mesher::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})];
let pipeline = |label, program: &CoreProgram, depth: wgpu::DepthStencilState| {
let module = |module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let (vertex, fragment) = (module(&program.vertex), module(&program.fragment));
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(depth),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let depth = |write, compare, bias| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(compare),
stencil: Default::default(),
bias,
};
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let polygon_offset = world.map(|program| {
pipeline(
"Resource pack polygon offset text",
program,
depth(true, wgpu::CompareFunction::GreaterEqual, offset),
)
});
let see_through = see_through.map(|program| {
pipeline(
"Resource pack see-through text",
program,
depth(false, wgpu::CompareFunction::Always, Default::default()),
)
});
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("the resource pack's world text shader is not used: {e}");
return None;
}
Some(Self {
polygon_offset,
see_through,
group,
uniform,
})
}
}
#[cfg(test)]
mod tests;
@@ -1,201 +0,0 @@
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn a_pack_text_shader_reads_the_light_as_vanilla_does() {
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping");
return;
};
let vsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\n\
layout(location = 2) in vec2 UV0;\nlayout(location = 3) in ivec2 UV2;\n\
layout(location = 0) flat out ivec2 light;\n\
void main() { gl_Position = vec4(Position, 1.0); light = UV2; }\n";
let fsh = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\
layout(location = 0) flat in ivec2 light;\nlayout(location = 0) out vec4 fragColor;\n\
void main() { fragColor = vec4(vec2(light) / 255.0, 0.0, 1.0); }\n";
let program =
crate::core_shader::translate_core(vsh, fsh, &[], &|_| None, &WORLD_GLUE).unwrap();
const SIZE: u32 = 64;
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
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,
view_formats: &[],
})
};
let binding = wgpu::TextureUsages::TEXTURE_BINDING;
let atlas = texture(wgpu::TextureFormat::Rgba8Unorm, binding).create_view(&Default::default());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&program),
None,
&atlas,
(&atlas, &sampler),
)
.expect("the pack's pipeline");
let target = wgpu::TextureUsages::RENDER_ATTACHMENT;
let color = texture(
wgpu::TextureFormat::Rgba8Unorm,
target | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(wgpu::TextureFormat::Depth32Float, target);
let light = 0x30 | (0xC0 << 8);
let corner = |x: f32, y: f32| crate::mesher::Vertex {
pos: [x, y, 0.5],
uv: [0.0; 2],
layer: 0,
color: u32::MAX,
light,
normal: [0; 4],
glint_uv: [0.0; 2],
};
let quad = [
corner(-1.0, -1.0),
corner(1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, -1.0),
corner(1.0, 1.0),
corner(-1.0, 1.0),
];
let vertices = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&quad),
usage: wgpu::BufferUsages::VERTEX,
},
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(text.polygon_offset.as_ref().unwrap());
pass.set_bind_group(0, &text.group, &[]);
pass.set_vertex_buffer(0, vertices.slice(..));
pass.draw(0..6, 0..1);
}
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
},
color.size(),
);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
let middle = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize;
assert_eq!(
&pixels[middle..middle + 4],
&[0xC0, 0x30, 0, 0xFF],
"UV2 is (block, sky)"
);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3, and a GPU"]
fn vanillas_text_shader_makes_both_world_text_pipelines() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
let read = |path: &str| std::fs::read_to_string(root.join(path)).ok();
let vertex = read("assets/minecraft/shaders/core/text.vsh").expect("vanilla's text.vsh");
let fragment = read("assets/minecraft/shaders/core/text.fsh").expect("vanilla's text.fsh");
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
read(&format!("assets/{namespace}/{path}"))
};
let translate = |defines: &[&str], glue: &Glue| {
crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue)
.expect("vanilla's text shader translates")
};
let world = translate(&[], &WORLD_GLUE);
let see_through = translate(&["IS_SEE_THROUGH"], &SEE_THROUGH_GLUE);
let (device, _) = device().expect("a GPU");
let texture = || {
device
.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let (atlas, lightmap) = (texture(), texture());
let sampler = device.create_sampler(&Default::default());
let text = CoreText::new(
&device,
wgpu::TextureFormat::Rgba8Unorm,
Some(&world),
Some(&see_through),
&atlas,
(&lightmap, &sampler),
)
.expect("the pack's world text pipelines");
assert!(text.polygon_offset.is_some() && text.see_through.is_some());
}
+5 -48
View File
@@ -37,9 +37,7 @@
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
//! same fragments pass, so the same sections are seen. Adreno's Vulkan driver
//! takes that path too: with the early test it now and then leaves sections
//! out for a frame.
//! same fragments pass, so the same sections are seen.
use std::num::NonZeroU64;
use std::sync::{
Arc,
@@ -222,42 +220,17 @@ const RESULTS_USAGE: wgpu::BufferUsages =
const READBACK_USAGE: wgpu::BufferUsages =
wgpu::BufferUsages::MAP_READ.union(wgpu::BufferUsages::COPY_DST);
/// The first `n` values of a packed lookup in WGSL `source` (`fn name`, as cull.wgsl's
/// `box_triangle_corner`): four bits each, eight a word, its words in the order of its cases.
#[cfg(test)]
pub(super) fn packed_lookup(source: &str, name: &str, n: usize) -> Vec<u32> {
let body = &source[source.find(&format!("fn {name}(")).expect("the lookup")..];
let body = &body[..body.find("return").expect("its return")];
let words: Vec<u32> = body
.split(|c: char| !c.is_ascii_alphanumeric())
.filter_map(|t| t.strip_prefix("0x")?.strip_suffix('u'))
.map(|hex| u32::from_str_radix(hex, 16).expect("a hex word"))
.collect();
(0..n)
.map(|v| (words[v / 8] >> (v % 8 * 4)) & 0xf)
.collect()
}
/// Qualcomm's PCI vendor id, which wgpu reports for Adreno GPUs.
const ADRENO: u32 = 0x5143;
impl Cull {
/// The device feature culling needs, where the adapter has it.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests (Adreno's in the shader), or Metal and DirectX 12 with the
/// test in the shader, all drawing an indirect multi-draw's entries with
/// the instance counts the GPU wrote, with fragment shaders that write
/// storage.
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
/// drawing an indirect multi-draw's entries with the instance counts the
/// GPU wrote, with fragment shaders that write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let info = adapter.get_info();
let depth_test = match info.backend {
// Adreno's Vulkan driver (the AYANEO Pocket DS's Adreno 740, build 0676.41) now and
// then leaves sections out with the early test, the world flashing black or
// see-through as a block breaks; the test in the shader, as on Metal, holds.
wgpu::Backend::Vulkan if info.vendor == ADRENO => DepthTest::InShader,
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan | wgpu::Backend::Gl
if device
.features()
@@ -799,20 +772,4 @@ mod tests {
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
}
}
/// The box's 12 triangles, as cull.wgsl and gpu_occlusion.wgsl draw them: two per face,
/// counter-clockwise seen from outside, corners by bit (x 1, y 2, z 4).
#[test]
fn the_packed_box_corners_are_the_box_triangles() {
let triangles = [
0, 6, 2, 0, 4, 6, 1, 3, 7, 1, 7, 5, 0, 1, 5, 0, 5, 4, 2, 7, 3, 2, 6, 7, 0, 3, 1, 0, 2,
3, 4, 5, 7, 4, 7, 6,
];
for source in [
include_str!("cull.wgsl"),
include_str!("gpu_occlusion.wgsl"),
] {
assert_eq!(packed_lookup(source, "box_triangle_corner", 36), triangles);
}
}
}
+6 -16
View File
@@ -34,21 +34,11 @@ struct Test {
@group(0) @binding(5) var<uniform> params: Params;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside: 0 6 2, 0 4 6, 1 3 7, 1 7 5, 0 1 5, 0 5 4,
// 2 7 3, 2 6 7, 0 3 1, 0 2 3, 4 5 7, 4 7 6.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
// counter-clockwise seen from outside.
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct Box {
@builtin(position) position: vec4<f32>,
@@ -105,7 +95,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
out.position = select(vec4(0.0, 0.0, 0.0, 1.0), vec4(p, 1.0, 1.0), v < 3u);
return out;
}
out.position = globals.view_proj * vec4(corner(origin, low, high, box_triangle_corner(v)), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
return out;
}
@@ -174,12 +174,7 @@ fn mesh() -> Vec<Vertex> {
}
impl CustomSky {
/// The layers' pipelines for a frame of `format`, holding linear light where `linear`
/// (`linear_blend`).
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
@@ -266,7 +261,10 @@ impl CustomSky {
"fs_custom_sky"
}),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -619,7 +617,7 @@ mod tests {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let sky = CustomSky::new(&device, (wgpu::TextureFormat::Rgba16Float, false));
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&sky.layout)],
@@ -663,37 +661,19 @@ mod tests {
let view = target.create_view(&Default::default());
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
// A frame of linear light (`FRAME_LINEAR`, `[frame] blend = "linear"`): the layer stored
// decoded, products kept exact.
let linear_modes = [
Blend::Replace,
Blend::Multiply,
Blend::Overlay,
Blend::Alpha,
]
.map(|m| {
(
MODES.iter().position(|&n| n == m).unwrap(),
m,
false,
false,
true,
)
});
for (mode_index, mode, debug, written, linear) in MODES
for (mode_index, mode, debug, written) in MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, true, false))
.chain([(5, Blend::Screen, true, true, false)])
.map(|(i, m)| (i, m, false, true))
.chain([(5, Blend::Screen, true, true)])
.chain(
MODES
.iter()
.copied()
.enumerate()
.map(|(i, m)| (i, m, false, false, false)),
.map(|(i, m)| (i, m, false, false)),
)
.chain(linear_modes)
{
let gain = if written { hdr_gain } else { 1.0 };
let uniform = Uniform {
@@ -737,10 +717,7 @@ mod tests {
module: &shader,
entry_point: Some(entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[
("HDR_WRITE_LIGHT", 1.0),
("FRAME_LINEAR", f64::from(u8::from(linear))),
],
constants: &[("HDR_WRITE_LIGHT", 1.0)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -847,17 +824,7 @@ mod tests {
(alpha - coverage).abs() < 0.001,
"{mode:?}: alpha {alpha}, expected {coverage}"
);
let decode = |v: f32| v.powf(2.2);
let expected = if linear {
// The sky under it is linear light, 2.0; the layer is gamma.
match mode {
Blend::Replace => decode(t),
Blend::Multiply => d * decode(1.0 + (t - 1.0) * k),
Blend::Overlay => d * decode(2.0 * (0.5 + (t - 0.5) * k)),
Blend::Alpha => decode(t) * k + d * (1.0 - k),
_ => unreachable!(),
}
} else if debug {
let expected = if debug {
let grey = (0.2126f32 * 0.8f32.powf(2.2)
+ 0.7152 * 0.4f32.powf(2.2)
+ 0.0722 * 0.2f32.powf(2.2))
@@ -878,11 +845,11 @@ mod tests {
};
assert!(
(got - expected).abs() < 0.004,
"{mode:?} debug {debug} linear {linear}: {got} expected {expected}"
"{mode:?} debug {debug}: {got} expected {expected}"
);
}
eprintln!(
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen and four on linear light, checking RGB and coverage"
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
);
}
}
@@ -56,25 +56,16 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
// dst * src: white leaves the sky alone. Decoded, the product stays exact:
// (dst * src)^2.2 = dst^2.2 * src^2.2.
case MULTIPLY: { return frame_stored(vec4(mix(vec3(1.0), t.rgb, k), 1.0)); }
// 2 * dst * src: grey leaves it alone. Decoded as entity.wgsl's crumbling, (2 src)^2.2 / 2,
// so it stays exact too.
case OVERLAY: {
let factor = mix(vec3(0.5), t.rgb, k);
if FRAME_LINEAR { return vec4(pow(2.0 * factor, vec3(2.2)) * 0.5, 1.0); }
return vec4(factor, 1.0);
}
case REPLACE: { return frame_stored(vec4(color, 1.0)); }
// mix(dst, src, alpha): alpha is coverage, kept.
case ALPHA: { return frame_stored(vec4(color, k)); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone. On
// linear light add and subtract add and take away light, as `"linear"` means to; dodge,
// burn and screen come out near their gamma results, not exactly.
default: { return vec4(frame_stored(vec4(color, 1.0)).rgb * k, 1.0); }
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(color, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(color, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(color * k, 1.0); }
}
}
+24 -124
View File
@@ -202,70 +202,34 @@ pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
}
/// A released Mesa version from its driver info, allowing distribution suffixes.
/// Development and release-candidate builds do not establish a stable release's safety.
fn parse_mesa_version(driver_info: &str) -> Option<(u32, u32, u32)> {
let version = driver_info
.strip_prefix("Mesa ")?
.split_whitespace()
.next()?;
let (version, suffix) = version.split_once('-').unwrap_or((version, ""));
if suffix.starts_with("devel") || suffix.starts_with("rc") {
return None;
}
let mut parts = version.split('.');
let version = (
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
);
parts.next().is_none().then_some(version)
}
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
/// Raptor Lake) ANV Vulkan drivers had broken `first_instance` for
/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
/// be drawn with another section's origin and flicker.
///
/// PR #15007 tested Raptor Lake (RPL-U, 0xa7a1) on Mesa 26.0.8 without terrain
/// flicker. Allow indirect multi-draw and GPU culling for Raptor Lake from that
/// release; earlier or unidentified drivers keep the workaround.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
if info.backend != wgpu::Backend::Vulkan || info.vendor != 0x8086 {
return false;
}
let is_raptor_lake = matches!(info.device, 0xa700..=0xa7ff)
|| info.name.contains("RPL")
|| info.name.contains("Raptor Lake");
if is_raptor_lake
&& parse_mesa_version(&info.driver_info).is_some_and(|version| version >= (26, 0, 8))
{
return false;
}
matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag))
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (matches!(
info.device,
0x8a50..=0x8a71 // Gen11: Ice Lake
| 0x9a40..=0x9a7f // Gen12: Tiger Lake
| 0x4c80..=0x4c9a // Gen12: Rocket Lake
| 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
| 0xa700..=0xa7ff // Gen12: Raptor Lake
) || [
"ICL",
"Ice Lake",
"TGL",
"Tiger Lake",
"RKL",
"Rocket Lake",
"ADL",
"Alder Lake",
"RPL",
"Raptor Lake",
]
.iter()
.any(|tag| info.name.contains(tag)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -1170,70 +1134,6 @@ mod tests {
assert!(!is_adreno_dx12(&info));
}
#[test]
fn intel_vulkan_detection_preserves_workaround_on_older_mesa_and_allows_modern_raptor_lake() {
let mut info = wgpu::AdapterInfo {
name: "Intel(R) Iris(R) Xe Graphics (RPL-U)".into(),
vendor: 0x8086,
device: 0xa7a1,
device_type: wgpu::DeviceType::IntegratedGpu,
device_pci_bus_id: String::new(),
driver: "Intel open-source Mesa driver".into(),
driver_info: "Mesa 26.0.8-1ubuntu0.3".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 0,
subgroup_max_size: 0,
transient_saves_memory: None,
limit_bucket: None,
};
for (driver_info, broken) in [
("Mesa 26.0.8-1ubuntu0.3", false),
("Mesa 26.0.8", false),
("Mesa 26.1.0", false),
("Mesa 27.0.0", false),
("Mesa 25.2.0", true),
("Mesa 26.0.0", true),
("Mesa 26.0.7", true),
("Mesa 26.0.8-devel", true),
("Mesa 27.0.0-rc1", true),
("Mesa 27.0.0-devel (git-abcdef)", true),
("Mesa 26", true),
("Mesa 26.0.8.1", true),
("Mesa unknown", true),
("26.0.8", true),
("", true),
] {
info.driver_info = driver_info.into();
assert_eq!(is_broken_intel_vulkan(&info), broken, "{driver_info}");
}
// PCI identification and the name fallback both allow the tested family.
info.driver_info = "Mesa 26.0.8".into();
info.device = 0;
assert!(!is_broken_intel_vulkan(&info));
info.name = "Intel Graphics".into();
info.device = 0xa7a1;
assert!(!is_broken_intel_vulkan(&info));
// Earlier families keep their workaround even with a modern Mesa release.
for (device, name) in [
(0x8a52, "Intel Ice Lake"),
(0x9a49, "Intel Tiger Lake"),
(0x4c8a, "Intel Rocket Lake"),
(0x46a8, "Intel Alder Lake"),
] {
info.device = device;
info.name = name.into();
assert!(is_broken_intel_vulkan(&info), "{name}");
}
// A Mesa-looking version cannot turn on this workaround for other backends/vendors.
info.backend = wgpu::Backend::Dx12;
assert!(!is_broken_intel_vulkan(&info));
info.backend = wgpu::Backend::Vulkan;
info.vendor = 0x10de;
assert!(!is_broken_intel_vulkan(&info));
}
/// Draws as `render` writes them: solid (page, quads at a base vertex)
/// first, then translucent (page, sorted page, count, first sorted index,
/// base vertex).
@@ -1,72 +0,0 @@
//! The frame policy: which frame a renderer draws, what the overlays draw on and whether
//! Horizon's tone curve runs (tests in `frame_policy_tests.rs`).
use super::{LINEAR_FRAME, WIDE_FRAME};
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
/// it, for a 0.2 pack, or for the layered HDR frame where the surface offers HDR output
/// (`linear`: `(a 0.2 pack picked, the layered HDR frame, the device draws it)`); else
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
/// `output::resolve_frame`), the surface offering HDR output (`hdr_offered`) and the device
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
/// shows them. Else the swapchain's 8-bit format, as without HDR.
pub(super) fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool, bool),
) -> wgpu::TextureFormat {
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// The frame's format, and whether it is the layered HDR frame (HUD coverage): for HDR on
/// under the switch (`hdr_float`), with no pack or a 0.2 one, where the device keeps the HUD
/// on a layer of its own.
pub(super) fn frame_configuration(
wide_frame: bool,
device: (bool, bool, bool, bool),
swapchain: wgpu::TextureFormat,
linear_pack: bool,
hdr_float: bool,
) -> (wgpu::TextureFormat, bool) {
let (hdr_offered, wide_ok, linear_ok, coverage_ok) = device;
let layered = hdr_float && hdr_offered && linear_ok && coverage_ok;
(
frame_format(
wide_frame,
hdr_offered,
wide_ok,
swapchain,
(linear_pack, layered, linear_ok),
),
layered,
)
}
/// What the hand, the camera overlays and the HUD draw on: the frame, except for a 0.2 pack's
/// float frame without the HUD's layer, which is SDR: the pack's picture finishes on the
/// swapchain's 8-bit format first, so they clip and blend per draw as on vanilla's frame.
pub(super) fn overlay_format(
frame: wgpu::TextureFormat,
layered: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if frame == LINEAR_FRAME && !layered {
swapchain.remove_srgb_suffix()
} else {
frame
}
}
/// Whether Horizon's tone curve (`Hdr::tonemap`) runs on this frame: on the layered frame's
/// world, unless a 0.2 pack's `[tonemap]` already mapped it. One tone mapping, never two.
pub(super) fn horizon_tone_curve(layered: bool, world: bool, pack_tonemap: bool) -> bool {
layered && world && !pack_tonemap
}
@@ -1,137 +0,0 @@
//! The frame policy: which frame a renderer draws (`frame_configuration`), and what follows
//! from the frame chosen.
#[test]
fn float_frames_never_schedule_light_mark_passes() {
use super::{LINEAR_FRAME, WIDE_FRAME, light_marks};
use wgpu::TextureFormat::{Bgra8Unorm, Rgb10a2Unorm, Rgba8Unorm};
for hdr in [false, true] {
assert!(!light_marks(LINEAR_FRAME, hdr));
for format in [WIDE_FRAME, Bgra8Unorm, Rgba8Unorm, Rgb10a2Unorm] {
assert_eq!(light_marks(format, hdr), hdr);
}
}
}
#[test]
fn frame_policy_keeps_compatibility_frames_and_requires_layering_for_float_hdr() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
for bits in 0..128 {
let hdr = bits & 1 != 0;
let offered = bits & 2 != 0;
let wide_ok = bits & 4 != 0;
let float_ok = bits & 8 != 0;
let coverage_ok = bits & 16 != 0;
let switch = bits & 32 != 0;
let smooth = bits & 64 != 0;
for pack in ["none", "0.1", "0.2", "shaders"] {
let float_hdr = matches!(pack, "none" | "0.2");
let expected = if switch && float_ok && float_hdr && hdr && offered && coverage_ok {
(LINEAR_FRAME, true)
} else if switch && float_ok && pack == "0.2" {
(LINEAR_FRAME, false)
} else if hdr && smooth && offered && wide_ok {
(WIDE_FRAME, false)
} else {
(Bgra8Unorm, false)
};
assert_eq!(
frame_configuration(
hdr && smooth,
(offered, wide_ok, float_ok, coverage_ok),
Bgra8UnormSrgb,
switch && pack == "0.2",
switch && hdr && float_hdr,
),
expected,
"bits {bits}, pack {pack}"
);
}
}
}
#[test]
fn float_hdr_falls_back_for_each_missing_hud_layer_requirement() {
use super::{LINEAR_FRAME, WIDE_FRAME, frame_configuration, hud_coverage};
use wgpu::{DownlevelFlags as D, TextureFormat as F, TextureFormatFeatureFlags as Fl};
use wgpu::{TextureFormatFeatures, TextureUsages as U};
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING;
for (attachments, flags, allowed_usages, format_flags) in [
(1, D::INDEPENDENT_BLEND, usages, Fl::BLENDABLE),
(2, D::empty(), usages, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::TEXTURE_BINDING, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, U::RENDER_ATTACHMENT, Fl::BLENDABLE),
(2, D::INDEPENDENT_BLEND, usages, Fl::empty()),
] {
let coverage = hud_coverage::supported(
attachments,
flags,
TextureFormatFeatures {
allowed_usages,
flags: format_flags,
},
);
let device = (true, true, true, coverage);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, false, true),
(WIDE_FRAME, false)
);
assert_eq!(
frame_configuration(false, device, F::Bgra8Unorm, false, true),
(F::Bgra8Unorm, false)
);
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, false),
(LINEAR_FRAME, false)
);
// A 0.2 pack keeps its float frame, without the layer: SDR.
assert_eq!(
frame_configuration(true, device, F::Bgra8Unorm, true, true),
(LINEAR_FRAME, false)
);
}
}
#[test]
fn a_0_2_pack_without_the_hud_layer_finishes_on_the_swapchains_8_bits() {
use super::{LINEAR_FRAME, WIDE_FRAME, overlay_format};
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm};
assert_eq!(
overlay_format(LINEAR_FRAME, false, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(overlay_format(LINEAR_FRAME, false, Rgba8Unorm), Rgba8Unorm);
// Everything else draws its overlays on its frame: the layered frame's light, the
// 16-bit frame, the 8-bit frame.
for (frame, layered) in [
(LINEAR_FRAME, true),
(WIDE_FRAME, false),
(Bgra8Unorm, false),
] {
assert_eq!(overlay_format(frame, layered, Bgra8UnormSrgb), frame);
}
}
#[test]
fn one_tone_curve_runs_on_the_layered_world() {
use super::horizon_tone_curve;
// No pack, or a 0.2 pack without `[tonemap]`: Horizon's curve.
assert!(horizon_tone_curve(true, true, false));
// A pack's `[tonemap]` mapped it already; never both.
assert!(!horizon_tone_curve(true, true, true));
// No world (the title screen), or no layered frame (SDR, the compatibility frames).
assert!(!horizon_tone_curve(true, false, false));
assert!(!horizon_tone_curve(false, true, false));
}
#[test]
fn only_a_no_pack_layered_world_applies_hdr_gain() {
use super::no_pack_hdr_gain;
assert!(no_pack_hdr_gain(true, false, true));
assert!(!no_pack_hdr_gain(false, false, true));
assert!(!no_pack_hdr_gain(true, true, true));
assert!(!no_pack_hdr_gain(true, false, false));
}
@@ -9,20 +9,11 @@
@group(0) @binding(12) var<storage, read> box_sections: array<Section>;
// The corners (bit 0: x, bit 1: y, bit 2: z) of the box's 12 triangles,
// counter-clockwise seen from outside (cull.wgsl's `box_triangle_corner`).
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn box_triangle_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x31640260u; }
case 1u: { word = 0x05105717u; }
case 2u: { word = 0x76237245u; }
case 3u: { word = 0x54320130u; }
default: { word = 0x00006747u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
// counter-clockwise seen from outside (cull.wgsl's).
const CORNERS = array<u32, 36>(
0u, 6u, 2u, 0u, 4u, 6u, 1u, 3u, 7u, 1u, 7u, 5u, 0u, 1u, 5u, 0u, 5u, 4u,
2u, 7u, 3u, 2u, 6u, 7u, 0u, 3u, 1u, 0u, 2u, 3u, 4u, 5u, 7u, 4u, 7u, 6u,
);
struct BoxOut {
@builtin(position) position: vec4<f32>,
@@ -35,7 +26,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
let o = bitcast<vec3<f32>>(box_origins[id].xyz);
var out: BoxOut;
out.id = id;
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, box_triangle_corner(v)), 1.0);
out.position = params.view_proj * vec4(box_corner(o, box_sections[id].a.z, CORNERS[v]), 1.0);
return out;
}
@@ -465,7 +465,7 @@ impl<'a> Draw<'a> {
}
fn material(&mut self, api: horizon_shader_api::Api) {
let source = super::super::native_material::coverage_test_shader((api, false));
let source = super::super::native_material::coverage_test_shader(api);
self.shader = self
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -478,7 +478,7 @@ impl<'a> Draw<'a> {
Vec::new(),
Default::default(),
None,
(api, false),
api,
);
self.queue.write_buffer(
&frame.buffer,
@@ -149,12 +149,6 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["terrain.wgsl", "visbuffer.wgsl"],
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
),
// Ray tracing's unpacking of compact sections (gfx/ray_scene.rs's `Unpacker`).
(
"ray tracing unpack",
vec!["terrain.wgsl", "rt_unpack.wgsl"],
super::surface_setup::ray_query::ray_scene::unpack_shader(),
),
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
(
"world warp terrain",
@@ -170,15 +164,10 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
),
(
"world primitives",
vec!["world_primitives.wgsl"],
super::world_primitives::shader(),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
super::border_shader()
include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"),
),
@@ -1,51 +0,0 @@
//! A shader API 0.2 pack's `[frame] blend = "linear"` (docs/design/hdr-linear-light.md, How the
//! frame stores the picture): its float frame holds linear light instead of vanilla's gamma, so
//! glass, water and smoke blend as light does. The pack's material, sky and weather stages store
//! their results as they are. Horizon's own draws into the frame decode theirs at the end of
//! their shaders (hdr_light.wgsl's `frame_stored`), so a solid surface looks as under `"gamma"`.
//! What the pack reads of the frame (`scene_color`, `opaque_color`, the water mirror) is copied
//! as it is instead of decoded. The tonemap encodes the picture into gamma again, so the hand,
//! the camera overlays, the HUD, the screenshots and HDR output see the same either way.
/// Whether a frame of `format` stores linear light for a 0.2 pack that asks for it (`linear`,
/// `FrameRequest::linear_blend`): only the float frame. Where the device can't draw that, the
/// 0.2 pack runs on the 8-bit frame, which keeps vanilla's gamma, as `"gamma"` does: eight bits
/// of linear light would band in the darks.
pub(super) fn stores_linear(linear: bool, format: wgpu::TextureFormat) -> bool {
linear && format == super::LINEAR_FRAME
}
/// The constants of a pipeline of Horizon's that draws into the world's frame of `format`
/// (hdr_light.wgsl): light written, not marked, on the float frame; and decoded where the
/// frame holds linear light (`linear`).
pub(super) fn frame_constants(
format: wgpu::TextureFormat,
linear: bool,
) -> [(&'static str, f64); 2] {
[
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
),
("FRAME_LINEAR", f64::from(u8::from(linear))),
]
}
/// The world's clear colour, given in vanilla's gamma, as a frame that holds linear light
/// (`linear`) stores it.
pub(super) fn stored_clear(color: wgpu::Color, linear: bool) -> wgpu::Color {
if !linear {
return color;
}
let decode = |v: f64| v.max(0.0).powf(2.2);
wgpu::Color {
r: decode(color.r),
g: decode(color.g),
b: decode(color.b),
a: color.a,
}
}
#[cfg(test)]
#[path = "linear_blend_tests.rs"]
mod tests;
@@ -1,413 +0,0 @@
use super::super::{
LINEAR_FRAME,
native_post::{Decoder, INTERMEDIATE, line_helpers},
};
use super::{frame_constants, stored_clear};
use horizon_shader_api::Api;
use wgpu::util::DeviceExt;
/// One full-screen draw into the frame: `vec4(rgb, alpha)` from Horizon's own shaders, which
/// give vanilla's gamma (`frame_stored`), or a 0.2 pack's, which gives linear light
/// (`horizon_line_out`).
#[derive(Clone, Copy)]
enum Writer {
Horizon,
Pack,
}
const SOURCE: &str = "
// The colour comes in a buffer: naga 30 rejects this fragment with the colour made of
// overrides (`ExpressionAlreadyInScope` once they are processed).
@group(0) @binding(0) var<uniform> test_color: vec4<f32>;
override TEST_PACK: bool = false;
@vertex fn test_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn test_fs() -> @location(0) vec4<f32> {
let c = test_color;
if TEST_PACK {
return vec4<f32>(horizon_line_out(c.rgb), c.a);
}
return frame_stored(c);
}
";
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// What a 0.2 stage reads of a 1x1 frame (`scene_color`): the world cleared to `clear`, in
/// vanilla's gamma, then `draws` alpha-blended over it, then the production decoding copy.
fn stage_input(
(device, queue): &(wgpu::Device, wgpu::Queue),
linear: bool,
clear: [f64; 3],
draws: &[(Writer, [f32; 4])],
) -> [f32; 4] {
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
view_formats: &[],
})
};
let frame = texture(LINEAR_FRAME, wgpu::TextureUsages::TEXTURE_BINDING);
let input = texture(INTERMEDIATE, wgpu::TextureUsages::COPY_SRC);
let frame_view = frame.create_view(&Default::default());
let source =
include_str!("../hdr_light.wgsl").to_owned() + line_helpers(Api::V0_2, linear) + SOURCE;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let mut encoder = device.create_command_encoder(&Default::default());
let [r, g, b] = clear;
let clear = stored_clear(wgpu::Color { r, g, b, a: 1.0 }, linear);
let mut load = wgpu::LoadOp::Clear(clear);
for &(writer, color) in draws {
let mut constants = frame_constants(LINEAR_FRAME, linear).to_vec();
constants.push((
"TEST_PACK",
f64::from(u8::from(matches!(writer, Writer::Pack))),
));
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vs"),
compilation_options: options.clone(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: LINEAR_FRAME,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
let color = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&color),
usage: wgpu::BufferUsages::UNIFORM,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: color.as_entire_binding(),
}],
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
load = wgpu::LoadOp::Load;
}
if let wgpu::LoadOp::Clear(_) = load {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
}
Decoder::new(device, linear).run(
device,
&mut encoder,
&frame_view,
&input.create_view(&Default::default()),
);
texel((device, queue), encoder, &input)
}
/// The first texel of the half-float `texture` once `encoder` ran.
fn texel(
(device, queue): (&wgpu::Device, &wgpu::Queue),
mut encoder: wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> [f32; 4] {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: None,
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.get_mapped_range(..).unwrap();
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
}
fn assert_near(got: [f32; 4], want: [f32; 3], what: &str) {
for i in 0..3 {
// Half floats, and a gamma frame's half float decoded, hold about three digits.
assert!(
(got[i] - want[i]).abs() < 0.003 * want[i].max(1.0),
"{what}: {got:?}, expected {want:?}"
);
}
assert_eq!(got[3], 1.0, "{what}: {got:?}");
}
// The background is gamma (0.8, 0.2, 0.5), linear (0.6121, 0.0290, 0.2176). Horizon's glass
// gives gamma (0.1, 0.9, 0.4), the pack's linear (0.0, 0.5, 1.0); both cover a quarter. Under
// "gamma" the frame mixes gamma, a stage reads `decode(0.25 * src + 0.75 * bg)`: Horizon's
// (0.3556, 0.1156, 0.1944), the pack's, encoded first, (0.3250, 0.0887, 0.3556). Under
// "linear" it mixes light, `0.25 * decode(src) + 0.75 * decode(bg)`: Horizon's
// (0.4606, 0.2200, 0.1965), the pack's (0.4590, 0.1467, 0.4132).
#[test]
fn translucent_draws_blend_in_the_light_the_frame_holds() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let background = [0.8, 0.2, 0.5];
for (linear, horizon, pack) in [
(false, [0.3556, 0.1156, 0.1944], [0.3250, 0.0887, 0.3556]),
(true, [0.4606, 0.2200, 0.1965], [0.4590, 0.1467, 0.4132]),
] {
let glass = [(Writer::Horizon, [0.1, 0.9, 0.4, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, horizon, &format!("Horizon's glass, linear {linear}"));
let glass = [(Writer::Pack, [0.0, 0.5, 1.0, 0.25])];
let got = stage_input(&gpu, linear, background, &glass);
assert_near(got, pack, &format!("the pack's glass, linear {linear}"));
}
}
// What a stage reads of solid surfaces is the same light under either blend: Horizon's gamma
// (0.3, 0.7, 1.5) decodes to (0.0707, 0.4563, 2.4401), the pack's light (0.25, 0.5, 2.0) and
// the clear colour, gamma (0.8, 0.2, 0.5), to (0.6121, 0.0290, 0.2176).
#[test]
fn stages_read_the_same_light_under_either_blend() {
let Some(gpu) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let clear = [0.8, 0.2, 0.5];
let cases = [
(
vec![(Writer::Horizon, [0.3, 0.7, 1.5, 1.0])],
[0.0707, 0.4563, 2.4401],
),
(
vec![(Writer::Pack, [0.25, 0.5, 2.0, 1.0])],
[0.25, 0.5, 2.0],
),
(vec![], [0.6121, 0.0290, 0.2176]),
];
for (draws, want) in cases {
let [gamma, linear] = [false, true].map(|linear| stage_input(&gpu, linear, clear, &draws));
assert_near(gamma, want, "gamma");
assert_near(linear, want, "linear");
for i in 0..3 {
assert!((gamma[i] - linear[i]).abs() < 0.003 * want[i].max(1.0));
}
}
}
// A section fading in mixes the pack's material result toward the fog colour in vanilla's
// gamma, as the fog does. Fog gamma 0.8, the pack's light 0.2 (gamma 0.2^(1/2.2) = 0.4812), half
// visible: under "linear" the frame stores (0.5 * 0.8 + 0.5 * 0.4812)^2.2 = 0.3757, not the
// 0.5 a mix in light gives; under "gamma" the result is already gamma, 0.5 * 0.8 + 0.5 * 0.2.
#[test]
fn a_fading_section_mixes_toward_the_fog_in_gamma() {
let Some((device, queue)) = gpu() else {
eprintln!("linear blend GPU unavailable");
return;
};
let globals = super::super::Globals {
fog_color: [0.8; 4],
// The pack's material result, as `horizon_material_color` gives it.
sky_color: [0.2, 0.2, 0.2, 1.0],
..bytemuck::Zeroable::zeroed()
};
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::bytes_of(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
for (linear, want) in [(false, 0.5), (true, 0.3757)] {
let source = super::super::native_material::coverage_test_shader((Api::V0_2, linear))
+ "
@fragment fn test_faded() -> @location(0) vec4<f32> {
var o: HorizonMaterialOut;
// Half visible (`horizon_material_vertex`), past 32 blocks.
o.hdr_code = 32768u << 16u;
o.spherical = 40.0;
return horizon_material_faded(o, horizon_terrain_globals.sky_color);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let constants = frame_constants(LINEAR_FRAME, linear)
.map(|(name, value)| (format!("horizon_terrain_{name}"), value));
let constants: Vec<(&str, f64)> = constants.iter().map(|(n, v)| (n.as_str(), *v)).collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("test_vertex"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("test_faded"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(LINEAR_FRAME.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: globals.as_entire_binding(),
}],
});
let frame = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: LINEAR_FRAME,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.create_view(&Default::default()),
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
let got = texel((&device, &queue), encoder, &frame);
assert!(
(got[0] - want).abs() < 0.002,
"linear {linear}: {got:?}, expected {want}"
);
assert_eq!(got[3], 1.0, "alpha kept");
}
}
// The frame O1 resolves (`output::resolve_frame`) holds linear light only for a 0.2 pack that
// asks for it, on the float frame. On a device without the float frame the pack draws anyway,
// in gamma. A blend changed is another frame, so the game makes the renderer again
// (`Renderer::frame_follows`).
#[test]
fn only_a_float_frame_holds_linear_light() {
use super::super::output::{FrameRequest, resolve_frame, tests::window};
let settings = crate::graphics::Graphics::default();
for float_frame in [false, true] {
for (linear_pack, linear_blend) in [(false, false), (true, false), (true, true)] {
let (mut caps, reading) = window(None, None);
caps.float_frame = float_frame;
let request = FrameRequest {
linear_pack,
linear_blend,
hdr_float: false,
};
let frame = resolve_frame(
&settings,
request,
caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(frame.format == LINEAR_FRAME, float_frame && linear_pack);
assert_eq!(
frame.linear_light,
float_frame && linear_blend,
"float {float_frame}, linear {linear_blend}"
);
}
}
}
@@ -22,9 +22,7 @@ const ENTRIES: &str = "
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
// In vanilla's gamma, as vanilla mixes it: on a frame of linear light, encoded for it.
let gamma = horizon_terrain_frame_gamma(color);
return horizon_terrain_frame_stored(horizon_terrain_apply_fog(gamma, spherical, cylindrical));
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
@@ -63,14 +61,11 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
}
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle. In
// vanilla's gamma, as the fog: on a frame of linear light, encoded for it.
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
let gamma = horizon_terrain_frame_gamma(color);
let faded = mix(horizon_terrain_globals.fog_color.rgb, gamma.rgb, visibility);
return horizon_terrain_frame_stored(vec4(faded, color.a));
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
}
// `light`: sky, block, 16 per level.
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
@@ -282,9 +277,8 @@ pub struct TerrainShape<'a> {
pub flat: Option<&'a wgpu::BindGroupLayout>,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`.
pub sprites: [f64; 2],
/// The world target's colour format, and whether it holds linear light (`linear_blend`).
/// The world target's colour format.
pub format: wgpu::TextureFormat,
pub linear: bool,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
@@ -375,12 +369,12 @@ fn shader_source(
tags: &[(u32, u32)],
materials: &[(u32, u32)],
(vertex, displaced): (bool, bool),
(api, linear): (horizon_shader_api::Api, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api, linear),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -404,9 +398,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
line: (horizon_shader_api::Api, bool),
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), line);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -430,7 +424,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
(frame.api, frame.linear),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -439,10 +433,10 @@ impl NativeMaterial {
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let [(_, write_light), (_, linear)] =
super::linear_blend::frame_constants(shape.format, frame.linear);
constants.push(("horizon_terrain_HDR_WRITE_LIGHT", write_light));
constants.push(("horizon_terrain_FRAME_LINEAR", linear));
constants.push((
"horizon_terrain_HDR_WRITE_LIGHT",
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
));
constants.push((
"horizon_displace_distance",
displaced.map_or(0.0, |d| f64::from(d.distance)),
@@ -592,9 +586,7 @@ impl NativeMaterial {
}
#[cfg(test)]
/// A material pack's terrain shader on shader API line `api`, its frame holding linear light
/// where `linear`, with a full-screen `test_vertex`.
pub(super) fn coverage_test_shader((api, linear): (horizon_shader_api::Api, bool)) -> String {
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = format!(
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
@@ -609,7 +601,7 @@ pub(super) fn coverage_test_shader((api, linear): (horizon_shader_api::Api, bool
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
shader_source(&stage.source, &[], &[], (false, true), (api, linear))
shader_source(&stage.source, &[], &[], (false, true), api)
+ r#"
override TEST_TIER: u32 = 0u;
override TEST_OVERLAY: u32 = 0u;
@@ -695,7 +687,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false, (Api::V0_1, false)).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -709,7 +701,7 @@ mod tests {
&tags,
&materials,
(false, false),
(Api::V0_1, false)
Api::V0_1
))
)
});
@@ -751,7 +743,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, (Api::V0_2, false)).unwrap();
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -774,20 +766,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true, (Api::V0_1, false)).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(
&stage.source,
&[],
&materials,
(false, true),
(Api::V0_1, false),
);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -844,7 +830,7 @@ mod tests {
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api, false)
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
@@ -129,7 +129,7 @@ impl NativePack {
textures,
lights,
terrain.pbr,
(api, terrain.linear && api == horizon_shader_api::Api::V0_2),
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
@@ -361,7 +361,6 @@ mod tests {
flat: None,
sprites: [1.0, 2048.0],
format: wgpu::TextureFormat::Rgba8Unorm,
linear: false,
sprite_tags: &[],
sprite_materials: &[],
entities: &empty,
@@ -16,57 +16,43 @@ use super::Frame;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power in, as the prelude's `horizon_decode`, and
/// out as the frame stores them: encoded, as `horizon_encode`, or where the frame holds linear
/// light (`linear`, `[frame] blend = "linear"`) as they are. The shadow pass has no prelude, so
/// these stand alone.
pub fn line_helpers(api: Api, linear: bool) -> &'static str {
match (api, linear) {
(Api::V0_1, _) => {
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
(Api::V0_2, false) => {
Api::V0_2 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
(Api::V0_2, true) => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
}
}
/// 0.2's decoding copy: a picture of the frame into linear light in `INTERMEDIATE`, of the same
/// size, alpha 1 (`scene_color`, `opaque_color`, the mirror). A frame of vanilla's gamma is
/// decoded; one that holds linear light (`linear`) is copied as it is.
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
pub(super) fn new(device: &wgpu::Device, linear: bool) -> Self {
let read = if linear { "c" } else { "horizon_line_in(c)" };
let source = line_helpers(Api::V0_2, false).to_owned()
+ &format!(
"@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {{
let c = textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb;
return vec4<f32>({read}, 1.0);
}}
"
);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
@@ -367,11 +353,9 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, whether the world's frame holds linear light for it
/// (`[frame] blend = "linear"`), and under 0.2 the decoding copy and the mirror it decoded
/// (`linear_mirror`).
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
pub api: Api,
pub linear: bool,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
@@ -449,16 +433,15 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack of the shader API line `api` with the `[shadow]` settings `shadow`, if
/// any, and the block light colours `lights`, onto a world's frame that holds linear light
/// when `linear` (a 0.2 pack's).
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
(api, linear): (Api, bool),
api: Api,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -582,8 +565,7 @@ impl NativeFrame {
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
linear,
decoder: (api == Api::V0_2).then(|| Decoder::new(device, linear)),
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
@@ -1777,7 +1759,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
@@ -1827,7 +1809,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
)
};
let (mut used, mut fresh) = (new(), new());
@@ -1893,7 +1875,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
);
let mut post = NativePost::new(
&device,
@@ -2006,7 +1988,7 @@ file = \"c.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
@@ -2134,7 +2116,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
);
let mut post = NativePost::new(
&device,
@@ -2254,7 +2236,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_1, false),
Api::V0_1,
);
let mut post = NativePost::new(
&device,
@@ -2589,7 +2571,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_2, false),
Api::V0_2,
);
let inputs = FrameInputs {
headroom: 1.0,
@@ -2673,7 +2655,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_2, false),
Api::V0_2,
);
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
assert_eq!(
@@ -2743,7 +2725,7 @@ file = \"b.wgsl\"
Vec::new(),
Default::default(),
None,
(Api::V0_2, false),
Api::V0_2,
);
let inputs = FrameInputs {
hdr: u32::from(hdr),
@@ -516,7 +516,7 @@ fn shader_source(
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
PREFIX
),
super::native_post::line_helpers(api, false)
super::native_post::line_helpers(api)
)
}
@@ -39,13 +39,12 @@ fn vanilla_sky() -> String {
prefixed(&text, PREFIX)
}
/// The sky shader for the pack's sky stage `source`, of the line `api`, onto a frame that holds
/// linear light when `linear`.
fn shader_source(source: &str, (api, linear): (horizon_shader_api::Api, bool)) -> String {
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api, linear)
super::native_post::line_helpers(api)
)
}
@@ -103,7 +102,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source, (frame.api, frame.linear));
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -199,7 +198,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source, (horizon_shader_api::Api::V0_1, false));
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -262,7 +261,7 @@ mod tests {
Vec::new(),
Default::default(),
None,
(horizon_shader_api::Api::V0_1, false),
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
@@ -42,8 +42,6 @@ const DISTANT_SQR: f64 = 3600.0;
const SECTION_DIAGONAL: f64 = 28.0;
/// In `Graph::rays`: the line from this corner stays in reached sections.
const SEES: u8 = u8::MAX;
/// Edits kept before a full resend.
const EDIT_CAP: usize = 1 << 16;
/// VisGraph connectivity per column, one entry per section (bottom first).
/// A missing column or section connects every face.
@@ -613,8 +611,6 @@ pub struct Occlusion {
queue: VecDeque<u32>,
/// Changes whenever `contains` may answer differently (`generation`).
generation: u64,
/// Set when edits were dropped; the next search resends every column.
resync: bool,
}
impl Occlusion {
@@ -635,7 +631,6 @@ impl Occlusion {
vis: Vec::new(),
queue: VecDeque::new(),
generation: next_generation(),
resync: false,
}
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
@@ -644,35 +639,13 @@ impl Occlusion {
}
}
fn changed(&mut self, key: SectionKey, vis: u64) {
self.dirty = true;
if self.edits.len() >= EDIT_CAP || self.changed.len() >= EDIT_CAP {
// nobody drains these with cave culling off
self.resync = true;
self.edits.clear();
self.changed.clear();
self.changed_late.clear();
}
if self.resync {
return;
}
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
}
fn resync_edits(&mut self) {
self.resync = false;
self.edits.clear();
self.edits.push(Edit::Clear);
for (&(x, z), column) in &self.columns.map {
for (y, &vis) in column.iter().enumerate() {
if vis != ALL_VISIBLE {
self.edits.push(Edit::Set((x, y as i32, z), vis));
}
}
}
}
/// Every section of the column `x`, `z` goes (sees through every face).
pub fn remove_column(&mut self, x: i32, z: i32) {
let Some(column) = self.columns.map.remove(&(x, z)) else {
@@ -701,7 +674,6 @@ impl Occlusion {
self.columns.map.clear();
self.edits.clear();
self.edits.push(Edit::Clear);
self.resync = false;
self.dirty = true;
// Another world: the old graph says nothing about it. Without one, every
// section was visible already, and stays so.
@@ -797,9 +769,6 @@ impl Occlusion {
let from = self.columns.section_of(camera);
self.dirty = false;
self.searched = Some((from, radius));
if self.resync {
self.resync_edits();
}
let graph = std::mem::take(&mut self.spare);
if let Some(worker) = &self.worker {
let job = Job {
@@ -1824,36 +1793,4 @@ mod tests {
assert_ne!(answers(&o, around, spread + radius), walled.1);
assert_ne!(o.generation(), walled.0, "the graph went");
}
#[test]
fn edit_queue_stays_bounded_without_searches() {
let mut o = Occlusion::new(0, 4);
let total = (1usize << 16) + 5000;
for i in 0..total {
o.set(((i % 700) as i32, 1, (i / 700) as i32), 1 + (i as u64 % 5));
}
assert!(o.edits.len() <= 1 << 16, "{} edits queued", o.edits.len());
assert!(
o.changed.len() <= 1 << 16,
"{} changed queued",
o.changed.len()
);
let mut fresh = Occlusion::new(0, 4);
for (&(x, z), column) in &o.columns.map {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
let camera = at((0, 1, 0));
for _ in 0..2 {
o.settle(camera, 6);
fresh.settle(camera, 6);
}
assert_eq!(
reached(&o),
reached(&fresh),
"the worker saw the same world"
);
}
}
+1 -1
View File
@@ -972,7 +972,7 @@ mod tests {
);
let border = stages("border");
validate(
&(super::super::border_shader()
&(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
@@ -69,7 +69,7 @@ impl Features {
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(super::border_shader()
(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
@@ -77,9 +77,6 @@ impl DisplayReading {
pub struct FrameRequest {
/// A shader API 0.2 pack is picked under `HORIZON_HDR_FRAME=float`: its frame is float.
pub linear_pack: bool,
/// That pack's `[frame] blend` is `"linear"`: its frame holds linear light where it is
/// float (`linear_blend`).
pub linear_blend: bool,
/// Under `HORIZON_HDR_FRAME=float`, with no pack available or a 0.2 one: the frame is
/// the layered float one wherever HDR output goes.
pub hdr_float: bool,
@@ -92,8 +89,6 @@ pub struct FrameConfiguration {
pub(crate) overlay_format: wgpu::TextureFormat,
/// The layered float HDR frame: an 8-bit HUD target and an R8 coverage target beside it.
pub(crate) hud_layer: bool,
/// The frame holds linear light, not vanilla's gamma (`linear_blend::stores_linear`).
pub(crate) linear_light: bool,
}
/// The display a 0.2 pack's `Frame` describes (docs/api/shaders-reference-0.2.md).
@@ -171,7 +166,6 @@ impl EffectiveOutput {
format: wgpu::TextureFormat::Bgra8Unorm,
overlay_format: wgpu::TextureFormat::Bgra8Unorm,
hud_layer: false,
linear_light: false,
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
@@ -265,7 +259,6 @@ pub(crate) fn resolve_frame(
format,
overlay_format: super::overlay_format(format, hud_layer, swapchain),
hud_layer,
linear_light: super::linear_blend::stores_linear(request.linear_blend, format),
}
}
@@ -390,12 +383,10 @@ pub(crate) mod tests {
fn the_resolver_picks_one_output_and_frame_for_each_combination() {
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let pack_0_2 = FrameRequest {
linear_pack: true,
linear_blend: false,
hdr_float: true,
};
// A 0.1 pack, or none without the float switch; a refused, missing or disabled
@@ -508,7 +499,6 @@ pub(crate) mod tests {
};
let request = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
@@ -751,7 +741,6 @@ pub(crate) mod tests {
};
let no_pack = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
let (caps, reading) = window(Some(Output::Pq), Some(true));
@@ -460,7 +460,6 @@ mod tests {
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
linear_blend: false,
hdr_float: true,
};
fn on() -> Graphics {
@@ -5,7 +5,6 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use wgpu::util::DeviceExt;
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -469,33 +468,19 @@ impl Buffers {
.iter()
.map(|(binding, o)| {
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(o, size, least);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
binds at most {largest}"
);
}
let size = wanted.min(largest);
// A buffer the pack initializes from a file starts with the file's bytes, the
// rest of it zero.
match o.initial_data() {
Some(data) => {
let mut contents = data.to_vec();
contents.resize(size as usize, 0);
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Shader pack buffer"),
contents: &contents,
usage: wgpu::BufferUsages::STORAGE,
})
}
None => device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size,
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
}),
}
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack buffer"),
size: wanted.min(largest),
usage: wgpu::BufferUsages::STORAGE,
mapped_at_creation: false,
})
})
.collect();
let entries: Vec<_> = self
@@ -524,7 +509,7 @@ impl Buffers {
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: &BufferObject, render: [u32; 2], least: u64) -> u64 {
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
@@ -627,18 +612,18 @@ mod tests {
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(&BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(&BufferObject::Bytes(40980), [1, 1], 40992),
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(&BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(&BufferObject::Bytes(41), [1, 1], 0), 44);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(&per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(&per_pixel, [4, 4], 64), 64);
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -399,20 +399,6 @@ impl Rotation {
#[cfg(test)]
mod tests {
use super::*;
/// CubeMap's cube: its corners' indices, six faces of two triangles.
#[test]
fn the_packed_cube_corners_are_cubemaps_indices() {
let indices = [
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1, 1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4, 7, 3, 2, 7, 2,
6, 0, 4, 5, 0, 5, 1,
];
let source = include_str!("panorama.wgsl");
assert_eq!(
super::super::cull::packed_lookup(source, "cube_corner", 36),
indices
);
}
use std::{
fs,
sync::atomic::{AtomicU32, Ordering},
@@ -8,27 +8,19 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) direction: vec3<f32>,
}
// The cube's corner of each of its 36 vertices (CubeMap's indices): 4 7 6, 4 6 5, 5 6 2,
// 5 2 1, 1 2 3, 1 3 0, 0 3 7, 0 7 4, 7 3 2, 7 2 6, 0 4 5, 0 5 1.
// Packed four bits each, eight a word, read through a switch: a constant array indexed by the
// vertex would be a private array, which Adreno's Vulkan compiler (build 0676.41) fails to link.
fn cube_corner(v: u32) -> u32 {
var word: u32;
switch v / 8u {
case 0u: { word = 0x65564674u; }
case 1u: { word = 0x13211252u; }
case 2u: { word = 0x47073003u; }
case 3u: { word = 0x40627237u; }
default: { word = 0x00001505u; }
}
return (word >> (v % 8u * 4u)) & 0xfu;
}
@vertex fn vs_panorama(@builtin(vertex_index) index: u32) -> Out {
// CubeMap's corners: -1 or 1 on each axis, x 1 at corners 1, 2, 5 and 6, y at 2, 3, 6
// and 7, z at 4 to 7.
let c = cube_corner(index);
let high = vec3((c ^ (c >> 1u)) & 1u, c & 2u, c & 4u) != vec3(0u);
let position = select(vec3(-1.0), vec3(1.0), high);
let corners = array<vec3<f32>, 8>(
vec3(-1.0, -1.0, -1.0), vec3(1.0, -1.0, -1.0),
vec3(1.0, 1.0, -1.0), vec3(-1.0, 1.0, -1.0),
vec3(-1.0, -1.0, 1.0), vec3(1.0, -1.0, 1.0),
vec3(1.0, 1.0, 1.0), vec3(-1.0, 1.0, 1.0),
);
let indices = array<u32, 36>(
4, 7, 6, 4, 6, 5, 5, 6, 2, 5, 2, 1,
1, 2, 3, 1, 3, 0, 0, 3, 7, 0, 7, 4,
7, 3, 2, 7, 2, 6, 0, 4, 5, 0, 5, 1,
);
let position = corners[indices[index]];
var out: Out;
out.clip = projection.view_proj * vec4(position, 1.0);
out.direction = position;
@@ -3,45 +3,9 @@
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The terrain's acceleration structures (step 2).
#[path = "ray_scene.rs"]
pub(in crate::gfx) mod ray_scene;
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether `device` was opened with ray queries.
pub(in crate::gfx) fn on(device: &wgpu::Device) -> bool {
device.features().contains(RAY_QUERY)
}
/// What buffers that BLAS builds read need on top of their own usage: nothing without ray
/// queries, so the off path's buffers are main's.
pub(in crate::gfx) fn blas_input(device: &wgpu::Device) -> wgpu::BufferUsages {
if on(device) {
wgpu::BufferUsages::BLAS_INPUT
} else {
wgpu::BufferUsages::empty()
}
}
/// The index buffer of `quads` quads (triangles 0 1 2 and 0 2 3), which the terrain draws
/// and BLAS builds both read.
pub(in crate::gfx) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
use wgpu::util::DeviceExt;
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
let b = q * 4;
[b, b + 1, b + 2, b, b + 2, b + 3]
})
.collect();
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Quad indices"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX | blas_input(device),
})
}
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
@@ -1,538 +0,0 @@
//! The terrain as acceleration structures, with ray tracing on (#13780 step 2, after the #1180
//! spike; `ray_query`): one BLAS per section's solid quads within `reach` chunks of the camera,
//! compacted once built, and one TLAS of them, built again only when a BLAS changes or the camera
//! enters another chunk column (its instances sit relative to that column). Nothing traces them
//! yet. Without ray queries on the device there is no scene and nothing here runs.
//!
//! The section arena keeps its compact layout: a section about to get a BLAS has its solid quads
//! written out as vertices first (`Unpacker`, `rt_unpack.wgsl`), as the solid draws expand them.
use glam::DVec3;
use crate::fast_hash::FxHashMap;
use crate::gfx::{Renderer, SectionGpu, SectionKey, arena::Arena, resident::Resident};
use crate::mesher::TerrainVertex;
/// BLAS builds per frame at most: a world that just loaded builds over a few frames.
const BUILDS_PER_FRAME: usize = 512;
/// Chunks around the camera the TLAS spans, unless `HORIZON_RT_CHUNKS` says otherwise. The
/// #1180 spike's TLAS over every section cost 2-7 ms of CPU a frame at render distance 32.
const REACH: i32 = 8;
/// The renderer's side: the calls gfx.rs makes, each one line there.
impl Renderer {
/// Section `key` got a new mesh, or lost it.
pub(in crate::gfx) fn ray_mark(&mut self, key: SectionKey) {
if let Some(rt) = &mut self.ray_scene {
rt.mark(key);
}
}
/// Every section is gone (a new world, a new dimension).
pub(in crate::gfx) fn ray_clear(&mut self) {
if let Some(rt) = &mut self.ray_scene {
rt.clear();
}
}
/// The frame's BLAS builds and TLAS, recorded in `encoder` before the world pass.
pub(in crate::gfx) fn ray_prepare(
&mut self,
encoder: &mut wgpu::CommandEncoder,
camera: DVec3,
title: bool,
) {
if let Some(rt) = &mut self.ray_scene
&& !title
{
crate::spans::time("ray scene", || {
rt.prepare(
&self.device,
&self.queue,
encoder,
&self.sections,
&self.arena,
&self.indices,
camera,
)
});
}
}
}
/// The terrain's acceleration structures.
pub(in crate::gfx) struct RayScene {
/// Each section's BLAS, while it has solid quads within reach.
blas: FxHashMap<SectionKey, wgpu::Blas>,
/// Sections meshed again or emptied since their BLAS was built.
dirty: Vec<SectionKey>,
tlas: wgpu::Tlas,
/// The camera's chunk column when the TLAS was built: its instances are placed relative to
/// that column's corner, so moving inside it needs no new TLAS.
anchor: Option<(i32, i32)>,
/// Chunks around the anchor the TLAS spans.
reach: i32,
/// BLAS built this frame, to measure for compaction once submitted.
to_compact: Vec<(SectionKey, wgpu::Blas)>,
/// BLAS whose compacted size the GPU is measuring.
compacting: Vec<(SectionKey, wgpu::Blas)>,
/// Sections in the arena's compact layout, written out as vertices for their BLAS.
unpacker: Unpacker,
}
impl RayScene {
/// The scene, when the device has ray queries (`ray_query`).
pub(in crate::gfx) fn new(device: &wgpu::Device) -> Option<Self> {
if !super::on(device) {
return None;
}
let reach = std::env::var("HORIZON_RT_CHUNKS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(REACH);
Some(Self {
blas: FxHashMap::default(),
dirty: Vec::new(),
tlas: create_tlas(device, 1024),
anchor: None,
reach,
to_compact: Vec::new(),
compacting: Vec::new(),
unpacker: Unpacker::new(device, 1 << 20),
})
}
fn mark(&mut self, key: SectionKey) {
self.dirty.push(key);
}
fn clear(&mut self) {
self.blas.clear();
self.dirty.clear();
self.to_compact.clear();
self.compacting.clear();
self.anchor = None;
}
/// Compacts BLAS built on earlier frames: built with room to spare, a compacted copy (made
/// once the GPU has measured it) takes about a quarter. Whether a BLAS was replaced.
fn compact(&mut self, queue: &wgpu::Queue) -> bool {
let mut changed = false;
let blas = &mut self.blas;
self.compacting.retain(|(key, b)| {
if blas.get(key) != Some(b) {
return false;
}
if !b.ready_for_compaction() {
return true;
}
blas.insert(*key, queue.compact_blas(b));
changed = true;
false
});
// Built and submitted last frame: the GPU can now measure them.
for (key, b) in std::mem::take(&mut self.to_compact) {
if self.blas.get(&key) == Some(&b) {
b.prepare_compaction_async(|_| {});
self.compacting.push((key, b));
}
}
changed
}
/// Builds the BLAS of sections marked since the last frame (at most `BUILDS_PER_FRAME`, and
/// as many as the unpacker has room for), and the TLAS again when a BLAS changed or the
/// camera entered another chunk column.
#[allow(clippy::too_many_arguments)]
fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
sections: &Resident<SectionGpu>,
arena: &Arena,
indices: &wgpu::Buffer,
camera: DVec3,
) {
let column = crate::gfx::resident::camera_column(camera);
// Only sections within `reach` get a BLAS (at render distance 32 all of them took 1.3 GB
// of video memory); those past `reach + 2` lose theirs, so walking along a chunk border
// doesn't build the same ones over and over.
let keep = self.reach + 2;
let mut changed = self.compact(queue);
if self.anchor != Some(column) {
let before = self.blas.len();
self.blas.retain(|k, _| within(k, column, keep));
changed |= self.blas.len() != before;
for (key, s, _) in sections.iter() {
if within(&key, column, self.reach) && s.solid > 0 && !self.blas.contains_key(&key)
{
self.dirty.push(key);
}
}
}
let take = self.dirty.len().min(BUILDS_PER_FRAME);
let mut builds: Vec<Build<'_>> = Vec::new();
let mut deferred = Vec::new();
// Vertices the unpacker writes this frame.
let mut unpacked = 0;
for key in self.dirty.drain(..take) {
if builds.iter().any(|b| b.key == key) {
continue;
}
match sections
.find(key)
.filter(|s| s.solid > 0 && within(&key, column, self.reach))
{
Some(s) => {
let at = if s.packed {
let vertices = u64::from(s.solid) * 4;
if unpacked + vertices > self.unpacker.capacity {
if unpacked > 0 {
// The rest waits for the next frame's room.
deferred.push(key);
continue;
}
self.unpacker.grow(device, vertices);
}
unpacked += vertices;
Some(unpacked - vertices)
} else {
None
};
let (size, blas) = new_blas(device, s.solid);
builds.push(Build {
key,
s,
size,
blas,
unpacked: at,
});
}
None => changed |= self.blas.remove(&key).is_some(),
}
}
self.dirty.extend(deferred);
if builds.is_empty() && !changed && self.anchor == Some(column) {
return;
}
self.unpacker.run(device, encoder, arena, &builds);
for b in &builds {
self.blas.insert(b.key, b.blas.clone());
self.to_compact.push((b.key, b.blas.clone()));
}
let near = |k: &SectionKey| within(k, column, self.reach);
let count = self.blas.keys().filter(|k| near(k)).count();
if count > self.tlas.get().len() {
self.tlas = create_tlas(device, (count as u32).next_power_of_two());
}
let min_y = sections.min_y();
let mut n = 0;
for (key, blas) in self.blas.iter().filter(|(k, _)| near(k)) {
let o = instance_origin(*key, min_y, column);
self.tlas[n] = Some(wgpu::TlasInstance::new(
blas,
[
1.0, 0.0, 0.0, o[0], 0.0, 1.0, 0.0, o[1], 0.0, 0.0, 1.0, o[2],
],
0,
0xff,
));
n += 1;
}
for i in n..self.tlas.get().len() {
if self.tlas[i].is_none() {
break;
}
self.tlas[i] = None;
}
let entries = build_entries(&builds, arena, &self.unpacker, indices);
encoder.build_acceleration_structures(entries.iter(), std::iter::once(&self.tlas));
self.anchor = Some(column);
}
}
/// Whether section `key` is within `reach` chunks of column `column`, along x and z.
fn within(key: &SectionKey, column: (i32, i32), reach: i32) -> bool {
(key.0 - column.0).abs() <= reach && (key.2 - column.1).abs() <= reach
}
/// A BLAS to build this frame.
struct Build<'a> {
key: SectionKey,
s: &'a SectionGpu,
size: wgpu::BlasTriangleGeometrySizeDescriptor,
blas: wgpu::Blas,
/// Its first vertex in the unpacker's buffer, for a section in the compact layout.
unpacked: Option<u64>,
}
impl Build<'_> {
/// The buffer holding its solid quads as vertices, and its first vertex there.
fn vertices<'b>(&self, arena: &'b Arena, unpacker: &'b Unpacker) -> (&'b wgpu::Buffer, u64) {
match self.unpacked {
Some(at) => (&unpacker.out, at),
None => (arena.buffer(self.s.page), self.s.start),
}
}
}
/// `rt_unpack.wgsl`'s `RtJob`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct RtJob {
base: u32,
quads: u32,
out: u32,
pad: u32,
}
/// Writes the solid quads of sections in the arena's compact layout out as vertices, for their
/// BLAS (BLAS builds read vertices; the records are only for the draws to expand). The buffer
/// is reused every frame: a frame takes only the sections it has room for.
struct Unpacker {
pipeline: wgpu::ComputePipeline,
out: wgpu::Buffer,
/// Vertices `out` holds.
capacity: u64,
}
impl Unpacker {
fn new(device: &wgpu::Device, capacity: u64) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Ray tracing: unpack sections"),
source: wgpu::ShaderSource::Wgsl(unpack_shader().into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Ray tracing: unpack sections"),
layout: None,
module: &module,
entry_point: Some("cs_rt_unpack"),
compilation_options: Default::default(),
cache: None,
});
Self {
pipeline,
out: unpacked_buffer(device, capacity),
capacity,
}
}
/// Room for at least `vertices` (one section larger than the buffer).
fn grow(&mut self, device: &wgpu::Device, vertices: u64) {
self.capacity = vertices.next_power_of_two();
self.out = unpacked_buffer(device, self.capacity);
}
/// Writes out the builds' sections that are in the compact layout, one dispatch per arena
/// page.
fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arena: &Arena,
builds: &[Build<'_>],
) {
let mut pages: Vec<u32> = builds
.iter()
.filter(|b| b.unpacked.is_some())
.map(|b| b.s.page)
.collect();
pages.sort_unstable();
pages.dedup();
if pages.is_empty() {
return;
}
let empty = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[],
});
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Ray tracing: unpack sections"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &empty, &[]);
for page in pages {
let jobs: Vec<RtJob> = builds
.iter()
.filter_map(|b| {
let at = b.unpacked.filter(|_| b.s.page == page)?;
Some(RtJob {
base: b.s.solid_base(),
quads: b.s.solid,
out: at as u32,
pad: 0,
})
})
.collect();
let jobs_buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
&wgpu::util::BufferInitDescriptor {
label: Some("Ray tracing: unpack jobs"),
contents: bytemuck::cast_slice(&jobs),
usage: wgpu::BufferUsages::STORAGE,
},
);
let arena_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(1),
entries: &[wgpu::BindGroupEntry {
binding: 19,
resource: arena.buffer(page).as_entire_binding(),
}],
});
let out_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &self.pipeline.get_bind_group_layout(2),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: jobs_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.out.as_entire_binding(),
},
],
});
let most = jobs.iter().map(|j| j.quads * 4).max().unwrap_or(0);
pass.set_bind_group(1, &arena_group, &[]);
pass.set_bind_group(2, &out_group, &[]);
pass.dispatch_workgroups(most.div_ceil(64), jobs.len() as u32, 1);
}
}
}
/// terrain.wgsl with the unpacking entry point after it, which reads sections as its solid
/// draws do (`section_corner`).
pub(in crate::gfx) fn unpack_shader() -> String {
crate::gfx::terrain_shader() + include_str!("../rt_unpack.wgsl")
}
fn unpacked_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Ray tracing: unpacked sections"),
size: vertices * std::mem::size_of::<TerrainVertex>() as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::BLAS_INPUT,
mapped_at_creation: false,
})
}
fn create_tlas(device: &wgpu::Device, capacity: u32) -> wgpu::Tlas {
device.create_tlas(&wgpu::CreateTlasDescriptor {
label: Some("Terrain sections"),
max_instances: capacity,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
})
}
/// A new BLAS for `solid` quads, and its size.
fn new_blas(
device: &wgpu::Device,
solid: u32,
) -> (wgpu::BlasTriangleGeometrySizeDescriptor, wgpu::Blas) {
let size = wgpu::BlasTriangleGeometrySizeDescriptor {
vertex_format: wgpu::VertexFormat::Float32x3,
vertex_count: solid * 4,
index_format: Some(wgpu::IndexFormat::Uint32),
index_count: Some(solid * 6),
flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE,
};
let blas = device.create_blas(
&wgpu::CreateBlasDescriptor {
label: None,
flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE
| wgpu::AccelerationStructureFlags::ALLOW_COMPACTION,
update_mode: wgpu::AccelerationStructureUpdateMode::Build,
},
wgpu::BlasGeometrySizeDescriptors::Triangles {
descriptors: vec![size.clone()],
},
);
(size, blas)
}
/// Build entries reading each section's solid vertices (`pos` is `TerrainVertex`'s first field)
/// with the shared quad indices.
fn build_entries<'a>(
builds: &'a [Build<'a>],
arena: &'a Arena,
unpacker: &'a Unpacker,
indices: &'a wgpu::Buffer,
) -> Vec<wgpu::BlasBuildEntry<'a>> {
let stride = std::mem::size_of::<TerrainVertex>() as u64;
builds
.iter()
.map(|b| {
let (source, first) = b.vertices(arena, unpacker);
wgpu::BlasBuildEntry {
blas: &b.blas,
geometry: wgpu::BlasGeometries::TriangleGeometries(vec![
wgpu::BlasTriangleGeometry {
size: &b.size,
vertex_buffer: source,
first_vertex: first as u32,
vertex_stride: stride,
index_buffer: Some(indices),
first_index: Some(0),
transform_buffer: None,
transform_buffer_offset: None,
},
]),
}
})
.collect()
}
/// Where section `key` sits relative to the corner of chunk column `anchor`.
fn instance_origin(key: SectionKey, min_y: i32, anchor: (i32, i32)) -> [f32; 3] {
[
((key.0 - anchor.0) * 16) as f32,
(min_y + key.1 * 16) as f32,
((key.2 - anchor.1) * 16) as f32,
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instances_sit_relative_to_the_anchor_column() {
// Section 5 of column (3, -2), the world starting at y = -64, seen from column (2, -2).
assert_eq!(instance_origin((3, 5, -2), -64, (2, -2)), [16.0, 16.0, 0.0]);
assert_eq!(instance_origin((2, 0, -2), -64, (2, -2)), [0.0, -64.0, 0.0]);
}
#[test]
fn the_unpack_shader_validates_with_terrain() {
let source = unpack_shader();
let module = naga::front::wgsl::parse_str(&source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
/// The off path is main's: a device opened without ray queries (every device without
/// `HORIZON_RAY_TRACING`, and every plain build) gets no scene, and its buffers no usage.
#[test]
fn without_ray_queries_there_is_no_scene() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, _)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
assert!(!super::super::on(&device));
assert!(RayScene::new(&device).is_none());
assert!(super::super::blas_input(&device).is_empty());
}
}
@@ -92,6 +92,7 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -60,11 +60,7 @@ pub(super) struct Stars {
}
impl Stars {
/// The stars onto a frame of `format`, holding linear light when `linear`.
pub(super) fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
) -> Self {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let triangles = star_triangles();
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Stars vertex buffer"),
@@ -139,7 +135,10 @@ impl Stars {
module: &shader,
entry_point: Some("fs_stars"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
constants: &[(
"HDR_WRITE_LIGHT",
f64::from(u8::from(format == super::LINEAR_FRAME)),
)],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -22,6 +22,6 @@ struct Stars {
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
}
@@ -7,7 +7,7 @@ use super::{
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
pub(super) mod ray_query;
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -57,7 +57,7 @@ impl Visbuffer {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
flat: &Flat,
(color, linear): (wgpu::TextureFormat, bool),
color: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> Self {
let flat_layout = &flat.draw_layout;
@@ -65,8 +65,13 @@ impl Visbuffer {
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.chain(super::linear_blend::frame_constants(color, linear))
.chain([
("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64),
(
"HDR_WRITE_LIGHT",
f64::from(u8::from(color == super::LINEAR_FRAME)),
),
])
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
@@ -14,10 +14,9 @@ pub(super) struct Weather {
count: u32,
}
impl Weather {
/// The weather's pipeline onto a frame of `format`, holding linear light when `linear`.
pub fn new(
device: &wgpu::Device,
(format, linear): (wgpu::TextureFormat, bool),
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
shader: &wgpu::ShaderModule,
) -> Self {
@@ -63,10 +62,7 @@ impl Weather {
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_weather"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -229,11 +225,11 @@ struct HorizonWeatherOut {
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str, (api, linear): (horizon_shader_api::Api, bool)) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api, linear)
super::native_post::line_helpers(api)
)
}
@@ -250,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source, (frame.api, frame.linear));
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -331,7 +327,7 @@ mod tests {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, (api, false));
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
@@ -7,11 +7,6 @@
use super::Vertex;
use glam::Mat4;
/// world_primitives.wgsl after what it shares with the frame's other shaders (`frame_stored`).
pub(super) fn shader() -> String {
include_str!("../hdr_light.wgsl").to_owned() + include_str!("world_primitives.wgsl")
}
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
@@ -62,12 +57,11 @@ pub struct WorldPrimitives {
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
impl WorldPrimitives {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth, decoding their
/// colours where the frame holds linear light (`linear`).
pub fn new(device: &wgpu::Device, (color_format, linear): (wgpu::TextureFormat, bool)) -> Self {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("World primitives"),
source: wgpu::ShaderSource::Wgsl(shader().into()),
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World primitives"),
@@ -156,10 +150,7 @@ impl WorldPrimitives {
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry[1]),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(color_format, linear),
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -477,7 +468,7 @@ mod tests {
lines_through: &lines_through,
};
let mut primitives = WorldPrimitives::new(&device, (format, false));
let mut primitives = WorldPrimitives::new(&device, format);
let atlas_view = atlas.create_view(&Default::default());
assert!(primitives.prepare(
&device,
@@ -41,7 +41,7 @@ struct QuadOut {
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
if color.a <= 0.0 { discard; }
return frame_stored(color);
return color;
}
struct LineOut {
@@ -73,5 +73,5 @@ struct LineOut {
}
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
return frame_stored(in.color);
return in.color;
}
@@ -3,7 +3,7 @@ pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
(format, linear): (wgpu::TextureFormat, bool),
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
@@ -34,10 +34,7 @@ pub(super) fn pipeline(
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_entity_unmarked"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &super::linear_blend::frame_constants(format, linear),
..Default::default()
},
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -66,8 +63,6 @@ mod tests {
label: None,
source: wgpu::ShaderSource::Wgsl(
r#"
override HDR_WRITE_LIGHT: bool = false;
override FRAME_LINEAR: bool = false;
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
return Out(vec4(pos,1.0),color);
@@ -77,12 +72,7 @@ struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
.into(),
),
});
let pipeline = pipeline(
&device,
None,
&shader,
(wgpu::TextureFormat::Rgba8Unorm, false),
);
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
@@ -148,8 +148,6 @@ pub(super) struct Kit {
pub entity: wgpu::BindGroupLayout,
pub static_entity: wgpu::BindGroupLayout,
pub format: wgpu::TextureFormat,
/// The frame holds linear light (`linear_blend`).
pub linear: bool,
pub sprite_constants: [(&'static str, f64); 2],
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
pub hdr_output: bool,
@@ -321,19 +319,20 @@ impl WorldWarps {
let buffered = [Some(vertices), Some(instances.clone())];
let pulled = [None, Some(instances)];
// As gfx.rs's: a float frame takes light where others take marks.
let [write_light, linear] = super::linear_blend::frame_constants(kit.format, kit.linear);
let write_light = (
"HDR_WRITE_LIGHT",
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
);
let lit = [
kit.sprite_constants[0],
kit.sprite_constants[1],
write_light,
linear,
];
let plain = [
kit.sprite_constants[0],
kit.sprite_constants[1],
("HDR_LIGHTING", 0.0),
write_light,
linear,
];
let depth = |write| wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
@@ -455,7 +454,7 @@ impl WorldWarps {
module: &entity,
entry_point: Some(look.fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &[write_light, linear],
constants: &[write_light],
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -66,5 +66,5 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return frame_stored(finished);
return finished;
}
+1 -14
View File
@@ -824,11 +824,6 @@ pub struct Graphics {
pub overlay: Overlay,
/// World resolution as a percentage of the window's (50..=200).
pub resolution_scale: u32,
/// Dynamic Resolution: below 100%, `resolution_scale` is the floor, and the scale rises
/// toward 100% while the GPU keeps `target_fps` (`crate::dynamic_resolution`). Off by default.
pub dynamic_resolution: bool,
/// The frame rate Dynamic Resolution keeps (30..=240).
pub target_fps: u32,
/// FXAA over the world.
pub fxaa: bool,
/// TAA over the world (FXAA is off while it is on).
@@ -925,8 +920,6 @@ impl Default for Graphics {
workers: 0,
overlay: Overlay::Off,
resolution_scale: 100,
dynamic_resolution: false,
target_fps: 60,
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
@@ -1088,8 +1081,6 @@ impl Graphics {
}
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"dynamicResolution" => g.dynamic_resolution = flag(g.dynamic_resolution),
"targetFps" => g.target_fps = number(g.target_fps).clamp(30, 240),
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
@@ -1144,7 +1135,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\ndynamicResolution:{}\ntargetFps:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -1181,8 +1172,6 @@ impl Graphics {
Overlay::Graph => "graph",
},
self.resolution_scale,
bool_text(self.dynamic_resolution),
self.target_fps,
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
@@ -1548,8 +1537,6 @@ mod tests {
workers: 6,
overlay: Overlay::Graph,
resolution_scale: 75,
dynamic_resolution: false,
target_fps: 60,
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
@@ -100,8 +100,6 @@ pub enum H {
Workers,
Overlay,
ResolutionScale,
DynamicResolution,
TargetFps,
Antialiasing,
Upscaling,
UpscalingSharpness,
@@ -368,16 +366,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
High,
"World pixels per screen pixel. Below 100% is faster on the GPU.",
),
h(
H::DynamicResolution,
Medium,
"Raises Resolution Scale toward 100% while the frame rate allows it.",
),
h(
H::TargetFps,
Low,
"The frame rate Dynamic Resolution keeps.",
),
h(
H::Antialiasing,
Medium,
@@ -807,7 +795,6 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::TargetFps => Some((g.target_fps as f32 - 30.0) / 210.0),
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
@@ -828,7 +815,6 @@ impl H {
H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
H::Workers => Some(max_workers()),
H::ResolutionScale => Some(30),
H::TargetFps => Some(42),
H::UpscalingSharpness => Some(20),
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
H::HdrPeak => Some(PEAK_POSITIONS),
@@ -928,8 +914,6 @@ impl H {
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled_percent("Resolution Scale", g.resolution_scale),
H::DynamicResolution => labelled("Dynamic Resolution", on_off(g.dynamic_resolution)),
H::TargetFps => labelled("Target FPS", g.target_fps),
H::Antialiasing => labelled(
"Anti-aliasing",
match (g.fxaa, g.taa) {
@@ -994,7 +978,6 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::TargetFps => g.target_fps = (30.0 + (v * 42.0).round() * 5.0) as u32,
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
H::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
@@ -1054,7 +1037,6 @@ impl H {
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DynamicResolution => g.dynamic_resolution = !g.dynamic_resolution,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
@@ -1205,7 +1187,6 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DynamicResolution) => g.dynamic_resolution,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
Item::Horizon(H::BatteryMode) => g.battery,
@@ -2723,7 +2704,6 @@ mod tests {
H::UploadBudget,
H::Workers,
H::ResolutionScale,
H::TargetFps,
H::UpscalingSharpness,
H::HdrPaperWhite,
H::HdrPeak,
@@ -1,22 +1,4 @@
override HDR_WRITE_LIGHT: bool = false;
// The frame holds linear light: a shader API 0.2 pack's `[frame] blend = "linear"`.
override FRAME_LINEAR: bool = false;
// What the frame stores of `c`, a colour in vanilla's gamma: `c`, or its light (vanilla's 2.2
// power) where the frame holds linear light, so what blends over it mixes as light does.
fn frame_stored(c: vec4<f32>) -> vec4<f32> {
if FRAME_LINEAR {
return vec4(pow(max(c.rgb, vec3(0.0)), vec3(2.2)), c.a);
}
return c;
}
// `frame_stored` undone: the stored colour `c` in vanilla's gamma.
fn frame_gamma(c: vec4<f32>) -> vec4<f32> {
if FRAME_LINEAR {
return vec4(pow(max(c.rgb, vec3(0.0)), vec3(1.0 / 2.2)), c.a);
}
return c;
}
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
+26 -149
View File
@@ -12,11 +12,8 @@ use std::{
};
use wgpu::util::DeviceExt;
use model_reducing::reducible_model_sprites;
mod animate;
pub mod cit_glint;
mod model_reducing;
mod pack_text;
pub mod pictures;
pub(crate) mod sprite_modifiers;
@@ -102,7 +99,9 @@ pub struct HudAtlas {
pub glint: (Vec<u8>, u32, u32),
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
pub glint_armor: (Vec<u8>, u32, u32),
pub text_shaders: crate::gfx::core_text::TextShaders,
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
@@ -979,7 +978,7 @@ impl HudAtlas {
font: crate::font::Font::metrics(root),
glint: (Vec::new(), 1, 1),
glint_armor: (Vec::new(), 1, 1),
text_shaders: Default::default(),
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -1048,7 +1047,7 @@ impl HudAtlas {
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
white,
text_shaders: Default::default(),
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -1058,6 +1057,25 @@ impl HudAtlas {
blinks: Default::default(),
}
}
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
@@ -1442,20 +1460,7 @@ impl HudAtlas {
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)
.or_else(|error| -> Result<_> {
let models = reducible_model_sprites(root, &images, &bordered);
let layout = Self::layout_reducing_models(
&mut images,
&mut gui_sizes,
&models,
MAX_ATLAS_SIZE,
)?;
log::warn!(
"reduced HUD model texture resolution to keep the resource pack: {error}"
);
Ok(layout)
})?;
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
@@ -1579,7 +1584,7 @@ impl HudAtlas {
sprite_animations,
glint,
glint_armor,
text_shaders: Default::default(),
text_shader: None,
lines_shader: None,
sky_shader: None,
stars_shader: None,
@@ -3773,47 +3778,6 @@ mod tests {
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
#[test]
fn a_root_single_sprite_strip_taller_than_the_atlas_packs_one_frame() {
let root = animated_root();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[{"type":"single","resource":"nexo:abc123"},
{"type":"single","resource":"nexo:still"}]}"#,
)
.unwrap();
// Plugin-generated names need no item/ prefix. Keep the production
// strip's height, but use narrow frames to keep this fixture cheap.
let textures = root.join("assets/nexo/textures");
fs::create_dir_all(&textures).unwrap();
write_strip(
&textures.join("abc123.png"),
16,
32768,
&[[255, 0, 0, 255], [0, 0, 255, 255]],
Some(r#"{"animation":{"frames":[1024,0],"frametime":3}}"#),
);
write_strip(&textures.join("still.png"), 16, 32, &[[7, 8, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["nexo:abc123"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the root-level single sprite animates");
assert_eq!(animation.frame(0), 1024);
assert_eq!(animation.frame(3), 0);
assert_eq!(animation.frame_count(), 2048);
assert_eq!(
atlas.sprites["nexo:still"].texels((atlas.width, atlas.height)),
(16, 32)
);
}
#[test]
fn an_animated_gui_sprite_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-gui-anim-{}", std::process::id()));
@@ -4220,93 +4184,6 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn model_resolution_fallback_protects_font_sources_and_gui_aliases() {
let root = animated_root();
write_png(&root, "item/font_sheet", 32, 32);
write_png(&root, "gui/panel", 32, 32);
write_png(&root, "hash", 32, 32);
let font = root.join("assets/minecraft/font/default.json");
fs::create_dir_all(font.parent().unwrap()).unwrap();
fs::write(
font,
r#"{"providers":[{"type":"bitmap","file":"minecraft:item/font_sheet.png","ascent":7,"chars":["a"]}]}"#,
).unwrap();
let atlases = root.join("assets/minecraft/atlases");
fs::create_dir_all(&atlases).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources":[
{"type":"single","resource":"item/font_sheet","sprite":"font_alias"},
{"type":"single","resource":"gui/panel","sprite":"gui_alias"},
{"type":"single","resource":"hash"}
]}"#,
)
.unwrap();
let images = vec![
("item/font_sheet".to_owned(), 32, 32, Vec::new()),
("item/blurred".to_owned(), 32, 32, Vec::new()),
("block/stone".to_owned(), 32, 32, Vec::new()),
];
let models =
reducible_model_sprites(&root, &images, &HashSet::from(["item/blurred".to_owned()]));
let _ = fs::remove_dir_all(root);
assert_eq!(
models,
HashSet::from(["hash".to_owned(), "block/stone".to_owned()])
);
}
#[test]
fn overflowing_model_textures_shrink_without_changing_gui_sizes_or_slots() {
let mut images = vec![
(
"nexo:hash".into(),
1024,
1024,
[10, 20, 30, 255].repeat(1024 * 1024),
),
(
"gui/panel".into(),
32,
32,
[40, 50, 60, 255].repeat(32 * 32),
),
("horizon/map".into(), 128, 128, Vec::new()),
];
let models = HashSet::from(["nexo:hash".to_owned(), "horizon/map".to_owned()]);
let mut sizes = HashMap::new();
assert!(HudAtlas::layout(&images, 1024).is_err());
let (width, placements, free) =
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_inside(&images, width, &placements, free);
assert!(free + GLYPH_ROWS <= 1024);
let model = images.iter().find(|i| i.0 == "nexo:hash").unwrap();
assert_eq!((model.1, model.2), (256, 256));
assert_eq!(model.3, [10, 20, 30, 255].repeat(256 * 256));
assert_eq!(sizes["nexo:hash"], (1024, 1024));
let gui = images.iter().find(|i| i.0 == "gui/panel").unwrap();
assert_eq!((gui.1, gui.2), (32, 32));
assert_eq!(gui.3, [40, 50, 60, 255].repeat(32 * 32));
let slot = images.iter().find(|i| i.0 == "horizon/map").unwrap();
assert_eq!((slot.1, slot.2), (128, 128));
assert!(slot.3.is_empty());
}
#[test]
fn fitting_models_keep_their_pixels_and_existing_logical_sizes() {
let mut images = vec![("item/gem".into(), 32, 32, [1, 2, 3, 255].repeat(32 * 32))];
let original = images.clone();
let mut sizes = HashMap::from([("item/gem".to_owned(), (16, 16))]);
let models = HashSet::from(["item/gem".to_owned()]);
HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).unwrap();
assert_eq!(images, original);
assert_eq!(sizes["item/gem"], (16, 16));
// Protected oversized images remain an error; no unbounded retry or
// silent removal of GUI/font resources when shrinking cannot help.
images.push(("font/sheet".into(), 1024, 1024, Vec::new()));
assert!(HudAtlas::layout_reducing_models(&mut images, &mut sizes, &models, 1024).is_err());
assert_eq!((images[0].1, images[0].2), (1024, 1024));
assert_eq!(sizes["item/gem"], (16, 16));
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
@@ -396,7 +396,6 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_some());
@@ -418,7 +417,6 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
other: Vec::new(),
};
gpu.hud.set_text_shader(&gpu.device, Some(&program));
assert!(gpu.hud.pack_text.is_none());
@@ -1,105 +0,0 @@
//! Resolution fallback for model textures that exceed the shared HUD atlas.
use super::{HashMap, HashSet, HudAtlas, PackedImage, Path, Result, bitmap_font_textures};
/// Only model atlas sprites may lose resolution. Font sheets can also be
/// model textures, so protect both their file names and renamed sources.
pub(super) fn reducible_model_sprites(
root: &Path,
images: &[PackedImage],
bordered: &HashSet<String>,
) -> HashSet<String> {
use crate::atlas_sources::{self, SpriteOrigin};
let fonts: HashSet<_> = bitmap_font_textures(root).into_iter().collect();
let protected = |name: &str| {
let path = name.split_once(':').map_or(name, |(_, path)| path);
path.starts_with("gui/")
|| path.starts_with("font/")
|| fonts.contains(&atlas_sources::texture_path(root, name, "textures"))
};
let mut models: HashSet<String> = images
.iter()
.filter(|(name, ..)| name.starts_with("item/") || name.starts_with("block/"))
.map(|(name, ..)| name.clone())
.collect();
let textures = atlas_sources::list_textures(root);
let mut protected_aliases = HashSet::new();
for (atlas, sources) in atlas_sources::atlases(root) {
if atlas != "minecraft:blocks" && atlas != "minecraft:items" {
continue;
}
for (id, origin) in atlas_sources::list(&sources, &textures) {
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
if let SpriteOrigin::File(texture) = origin {
if protected(&texture) {
protected_aliases.insert(name);
} else {
models.insert(name);
}
}
}
}
models.retain(|name| {
!bordered.contains(name) && !protected(name) && !protected_aliases.contains(name)
});
models
}
impl HudAtlas {
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
/// holds, without them: they are left out, not the packs.
pub(super) fn layout_leaving_out(
images: &mut Vec<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
match Self::layout(images, max) {
Err(e) if !extras.is_empty() => {
images.retain(|i| !extras.contains(&i.0));
animated.retain(|n| !extras.contains(n));
let layout = Self::layout(images, max)?;
log::warn!("left out the custom item textures: {e}");
Ok(layout)
}
layout => layout,
}
}
/// Last resort for a pack that exceeds the shared atlas: halve model
/// textures until they fit, retaining their logical GUI sizes. Never
/// shrink run-time slots (empty pixels), or below the vanilla 16 texels.
pub(super) fn layout_reducing_models(
images: &mut [PackedImage],
gui_sizes: &mut HashMap<String, (u32, u32)>,
models: &HashSet<String>,
max: u32,
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
loop {
let error = match Self::layout(images, max) {
Ok(layout) => return Ok(layout),
Err(error) => error,
};
let mut changed = false;
for (name, w, h, pixels) in images.iter_mut() {
if !models.contains(name) || pixels.is_empty() || (*w <= 16 && *h <= 16) {
continue;
}
let (nw, nh) = ((*w / 2).max((*w).min(16)), (*h / 2).max((*h).min(16)));
let mut scaled = Vec::with_capacity((nw * nh * 4) as usize);
for y in 0..nh {
for x in 0..nw {
let at = (((y * *h / nh) * *w + x * *w / nw) * 4) as usize;
scaled.extend_from_slice(&pixels[at..at + 4]);
}
}
gui_sizes.entry(name.clone()).or_insert((*w, *h));
*pixels = scaled;
(*w, *h) = (nw, nh);
changed = true;
}
if !changed {
return Err(error);
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
}
}
}
@@ -76,8 +76,9 @@ pub(super) fn load_core(
}
}
/// The packs' core shaders the atlas carries (text, block outline, sky, stars).
pub(super) fn load_shaders(atlas: &mut super::HudAtlas, root: &Path, defaults: &Path) {
atlas.text_shaders.gui = load(root, defaults);
atlas.text_shader = load(root, defaults);
atlas.lines_shader = load_core(
root,
defaults,
@@ -88,12 +89,6 @@ pub(super) fn load_shaders(atlas: &mut super::HudAtlas, root: &Path, defaults: &
let sky = |name| load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
atlas.sky_shader = sky("sky");
atlas.stars_shader = sky("stars");
let world = |defines: &[&str], glue| load_core(root, defaults, "text", defines, glue);
atlas.text_shaders.world = world(&[], &crate::gfx::core_text::WORLD_GLUE);
atlas.text_shaders.see_through = world(
&["IS_SEE_THROUGH"],
&crate::gfx::core_text::SEE_THROUGH_GLUE,
);
}
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
@@ -39,14 +39,11 @@ pub fn text_with_backdrop(
background: u32,
) {
if background != 0 {
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
// the glyphs, the glyph advance to the right and nine high: not a
// fixed two-pixel frame.
out.push(fill(
x - 1,
y - 1,
x + w,
y + 9,
x - 2,
y - 2,
x + w + 2,
y + 9 + 2,
multiply(background, color),
));
}
@@ -1487,27 +1484,3 @@ mod boss_bar_room_tests {
}
}
}
#[cfg(test)]
mod backdrop_tests {
use super::*;
/// `markBackground`: the backdrop reaches one pixel above and left of the
/// glyphs, their advance to the right and nine down, not 2 on every side.
#[test]
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
let mut out = Vec::new();
text_with_backdrop(
&mut out,
Text::literal("X"),
10,
20,
6,
u32::MAX,
0xFF00_0000,
);
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
panic!("a backdrop is a fill first");
};
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
}
}
-1
View File
@@ -43,7 +43,6 @@ pub mod crash;
pub mod custom_sky;
pub mod datapack;
pub mod dynamic_light;
pub mod dynamic_resolution;
pub mod effects;
pub mod environment;
pub mod extras;
+46 -79
View File
@@ -21,7 +21,9 @@ use crate::{
models::catalog::BakedFace,
};
use horizon_assets::model::Direction as ModelDirection;
use horizon_core::{biome::BiomeZoom, geometry::Direction, position::BlockPos};
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
use horizon_world::{
block_render::FaceVisibility,
@@ -57,16 +59,6 @@ pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
pub const SHADED_CULL_W: i8 = -126;
/// `Vertex::normal` of a quad of an `entitySolid` model, which culls its back faces: a quad
/// the shader shades by its normal keeps it ([`SHADED_CULL_W`]), a baked-colour one (no
/// normal) gets [`CULL_BACK`].
pub fn cull_back(normal: [i8; 4]) -> [i8; 4] {
if normal == [0; 4] {
CULL_BACK
} else {
[normal[0], normal[1], normal[2], SHADED_CULL_W]
}
}
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
@@ -153,17 +145,6 @@ impl TerrainVertex {
}
}
#[cfg(test)]
mod cull_back_tests {
use super::{CULL_BACK, SHADED_CULL_W, cull_back};
/// #15322: a shaded `entitySolid` quad keeps its normal and culls; a baked one has
/// no normal and only culls.
#[test]
fn cull_back_keeps_a_shaded_normal() {
assert_eq!(cull_back([0; 4]), CULL_BACK);
assert_eq!(cull_back([0, 127, 0, 127]), [0, 127, 0, SHADED_CULL_W]);
}
}
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
@@ -925,7 +906,19 @@ impl MeshTables {
|| (matches!(name, "sculk_sensor" | "calibrated_sculk_sensor")
&& props.get("sculk_sensor_phase").map(String::as_str) == Some("active"));
// OffsetType.XZ/XYZ with getMaxHorizontalOffset/getMaxVerticalOffset.
let offset = s.max_offset();
let offset = s.random_offset.then(|| {
let horizontal = if s.has_class("SpeleothemBlock") {
0.125
} else {
0.25
};
let vertical = match name {
"small_dripleaf" => 0.1,
"short_grass" | "fern" | "short_dry_grass" | "tall_dry_grass" => 0.2,
_ => 0.0,
};
(horizontal, vertical)
});
let mut face = [0u8; 6];
let mut cover = [0f32; 6];
// A server's visibility knows no state (`FaceVisibility::empty`):
@@ -1209,7 +1202,15 @@ impl MeshTables {
}
/// `OffsetType.XZ`/`XYZ`: a per-column jitter from `Mth.getSeed(x, 0, z)`.
fn offset(info: &Info, x: i32, z: i32) -> Option<[f64; 3]> {
Some(horizon_items::blocks::column_offset(info.offset?, x, z))
let (horizontal, vertical) = info.offset?;
let seed = position_seed(BlockPos::new(x, 0, z));
let nibble = |shift: u32| ((seed >> shift) & 15) as f32 / 15.0;
let h = f64::from(horizontal);
Some([
((f64::from(nibble(0)) - 0.5) * 0.5).clamp(-h, h),
(f64::from(nibble(4)) - 1.0) * f64::from(vertical),
((f64::from(nibble(8)) - 0.5) * 0.5).clamp(-h, h),
])
}
/// Border width: smooth lighting samples up to two blocks out along a face normal.
const B: i32 = 2;
@@ -2259,14 +2260,22 @@ impl Mesher<'_> {
translucent: face.translucent,
opaque: face.opaque,
};
let own_translucent = self.translucent(own);
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => {
let v = corners.map(|c| c.0);
self.quad_as(out, force_solid, tile(t), v, false, tint);
}
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -2275,7 +2284,7 @@ impl Mesher<'_> {
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
let piece = piece.map(|c| c.0);
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
}
}
}
@@ -2288,7 +2297,12 @@ impl Mesher<'_> {
if count == 0 {
return;
}
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
let face_translucent = match pick {
Pick::Keep => self.translucent(own),
Pick::Tile(t) => self.translucent(tile(t)),
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
Pick::Nothing => false,
};
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
// rotations).
@@ -2437,21 +2451,6 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
}
out
}
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
match t {
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
layer,
translucent: own_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
}
}
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
@@ -2576,38 +2575,6 @@ mod quarter_tests {
);
}
#[test]
fn a_tile_draws_in_its_faces_pass() {
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
let blade = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
let face = |translucent| TileLayer::Layer {
layer: 1,
translucent,
opaque: !translucent,
};
let cut = TileLayer::Layer {
layer: 5,
translucent: false,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(false), false), cut);
// Over stained glass the tile blends, as the face does.
let blend = TileLayer::Layer {
layer: 5,
translucent: true,
opaque: false,
};
assert_eq!(in_face_pass(blade, face(true), true), blend);
assert_eq!(
in_face_pass(TileLayer::Default, face(false), false),
face(false)
);
}
#[test]
fn overlays_take_the_cubes_own_uvs() {
use horizon_assets::model::Direction as D;
+65 -28
View File
@@ -2,14 +2,10 @@
//! Controls, Key Binds, Mouse Settings, Chat Settings, Accessibility,
//! Language and Resource Packs, laid out like vanilla's HeaderAndFooter /
//! OptionsList screens and drawn with the shared widgets.
mod pack_drop;
use std::time::Instant;
pub use crate::settings::Opt;
#[path = "options_ui_widgets.rs"]
mod widgets;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
@@ -21,11 +17,6 @@ use crate::{
text::{self, Style, Text},
ui,
};
pub(crate) use widgets::{button, inactive};
#[path = "options_ui_values.rs"]
mod values;
pub(crate) use values::{chat_delay_label, chat_px, generic, on_off, percent};
mod shell_pages;
@@ -44,6 +35,18 @@ pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::At
fn horizon_extras() -> bool {
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
}
fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `text` cut into lines of at most `width` characters, at spaces where it has them.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
@@ -68,11 +71,20 @@ fn wrap(text: &str, width: usize) -> Vec<String> {
}
lines
}
fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
/// A music track's name as its row shows it: the file, without its folder
/// (`minecraft:music/game/calm1` -> `calm1`; the group names the situation).
fn track_name(id: &str) -> &str {
id.rsplit('/').next().unwrap_or(id)
}
fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) enum Kind {
/// Slider position 0..1.
Slider(f32),
@@ -341,7 +353,6 @@ impl Opt {
pub(crate) fn kind(self, s: &Settings) -> Kind {
let unit = |v: f32, lo: f32, hi: f32| Kind::Slider(((v - lo) / (hi - lo)).clamp(0.0, 1.0));
match self {
Opt::ChatDelay => values::kind(s.file.double("chatDelay")),
Opt::FovEffects | Opt::DarknessPulsing => {
Kind::Slider(s.file.double(self.file_key().unwrap()).sqrt() as f32)
}
@@ -450,7 +461,6 @@ impl Opt {
/// The discrete step count for stepped sliders, or `None` if continuous.
pub fn slider_steps(self) -> Option<u32> {
match self {
Opt::ChatDelay => Some(60),
Opt::Fov => Some(80),
Opt::RenderDistance => Some((crate::options_file::MAX_RENDER_DISTANCE - 2) as u32),
Opt::SimulationDistance => Some(27),
@@ -501,7 +511,7 @@ impl Opt {
pub fn label(self, s: &Settings) -> Text {
let c = self.caption();
match self {
Opt::ChatDelay => chat_delay_label(s),
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::FovEffects
| Opt::DarknessPulsing
| Opt::DistortionEffects
@@ -706,7 +716,7 @@ impl Opt {
} else {
"options.narrator.notavailable"
}),
Opt::ChatDelay => chat_delay_label(s),
Opt::ChatDelay => tr("options.chat.delay_none"),
Opt::HideLightningFlashes => on_off(s.file.bool("hideLightningFlashes")),
Opt::MonochromeLogo => on_off(s.file.bool("darkMojangStudiosBackground")),
Opt::HideSplashTexts => on_off(s.file.bool("hideSplashTexts")),
@@ -891,7 +901,6 @@ impl Opt {
let v = v.clamp(0.0, 1.0);
let step = |lo: f32, hi: f32| (lo + v * (hi - lo)).round();
match self {
Opt::ChatDelay => values::set(s, v),
Opt::FovEffects | Opt::DarknessPulsing => s.file.set(
self.file_key().unwrap(),
crate::options_file::V::Double(f64::from(v).powi(2)),
@@ -1297,7 +1306,7 @@ pub enum Page {
Music,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Act {
enum Act {
None,
Push(Page),
Done,
@@ -1362,7 +1371,7 @@ enum WKind {
code: String,
},
}
pub(crate) struct Widget {
struct Widget {
x: f32,
y: f32,
w: f32,
@@ -1417,8 +1426,6 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
/// What the game must do after an interaction.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
/// Dropped resource packs accepted on the confirmation screen.
ImportPacks(Vec<std::path::PathBuf>),
/// The Pack Options page closed with changes: the player's choices of its entries, the
/// ones that differ from their defaults (the others went back to theirs).
PackOptions {
@@ -1700,6 +1707,27 @@ impl Default for OptionsUi {
}
}
}
fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
// OptionsSubScreen.addContents: the narrator button is active only while
// the narrator is (`narratorButton.active = narrator.isActive()`).
@@ -1971,6 +1999,23 @@ impl OptionsUi {
Page::Video | Page::Controls | Page::KeyBinds | Page::ModBrowser
)
}
/// Whether the resource pack screen is open (not Create World's data packs on the same
/// page).
pub fn resource_packs_open(&self) -> bool {
self.page() == Page::Packs && self.data_pack_dir.is_none()
}
/// The packs in `resourcepacks/` changed while the pack screen is open
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
/// `infos`, keeping the selection made on it so far.
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
if let Some(p) = self.pack_screen.take()
&& p.changed
{
self.pack_selection = p.commit();
self.pack_reselected = true;
}
self.pack_infos = infos;
}
pub fn page(&self) -> Page {
*self.stack.last().unwrap_or(&Page::Main)
}
@@ -3049,20 +3094,13 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let reset_all = button(
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
tr("controls.resetAll"),
Act::ResetKeys,
);
// KeyBindsScreen.extractRenderState: active only while some
// binding differs from its default.
out.push(if s.keys.changed_from_defaults() {
reset_all
} else {
inactive(reset_all)
});
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
}
Page::Language => {
@@ -3820,7 +3858,6 @@ impl OptionsUi {
self.pack_focus = Instant::now();
}
match action {
crate::pack_screen::Action::Import(paths) => events.push(Event::ImportPacks(paths)),
crate::pack_screen::Action::OpenFolder => events.push(match &self.data_pack_dir {
Some(dir) => Event::OpenDataPackFolder(dir.clone()),
None => Event::OpenPackFolder,
@@ -1,68 +0,0 @@
//! Resource-pack drop confirmation and selection-preserving list refresh.
use super::*;
impl OptionsUi {
/// Whether the resource pack screen is open (not Create World's data packs on the same
/// page).
pub fn resource_packs_open(&self) -> bool {
self.page() == Page::Packs && self.data_pack_dir.is_none()
}
/// Returns whether the Resource Packs screen owns this drop; data packs keep their route.
pub fn drop_resource_pack(
&mut self,
path: std::path::PathBuf,
atlas: &HudAtlas,
size: (f32, f32),
) -> bool {
if !self.resource_packs_open() {
return false;
}
if self.resource_error.is_none() {
self.packs(atlas, size).drop_file(path);
}
true
}
/// The packs in `resourcepacks/` changed while the pack screen is open
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
/// `infos`, keeping the selection made on it so far.
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
if let Some(p) = self.pack_screen.take()
&& p.changed
{
self.pack_selection = p.commit();
self.pack_reselected = true;
}
self.pack_infos = infos;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_resource_packs_receive_drops_and_yes_emits_the_import_request() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let size = (480.0, 270.0);
let mut ui = OptionsUi::default();
let mut settings = Settings::default();
assert!(!ui.drop_resource_pack("Ignored.zip".into(), &atlas, size));
ui.open_at(false, Page::Packs);
assert!(ui.drop_resource_pack("Pack.zip".into(), &atlas, size));
// The ConfirmScreen's Yes button, centred at y = height/2 + 15.
let events = ui.click(
&mut settings,
&atlas,
90.0,
156.0,
size,
[480, 270],
false,
false,
);
assert!(events.iter().any(|event| matches!(event, Event::ImportPacks(paths) if paths == &[std::path::PathBuf::from("Pack.zip")])));
ui.data_pack_dir = Some("datapacks".into());
assert!(!ui.drop_resource_pack("Data.zip".into(), &atlas, size));
}
}
@@ -174,42 +174,3 @@ fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
assert_eq!(ui.graphics.screen.output.held, held);
}
}
/// The slider position `Opt::ChatDelay`'s kind() gives the stored seconds.
fn delay_pos(seconds: f64) -> f32 {
match values::kind(seconds) {
Kind::Slider(p) => p,
_ => panic!("the chat delay is a slider"),
}
}
/// `Options.chatDelay`: `IntRange(0, 60)` xmap(v / 10) — a slider in tenths of
/// a second from 0 to 6, not a button with no value behind it.
#[test]
fn the_chat_delay_slider_steps_in_tenths() {
assert_eq!(delay_pos(0.0), 0.0);
assert!((delay_pos(3.0) - 0.5).abs() < 1e-6);
assert_eq!(delay_pos(6.0), 1.0);
// A value past the option's range clamps to its last step.
assert_eq!(delay_pos(6.5), 1.0);
assert_eq!(Opt::ChatDelay.slider_steps(), Some(60));
let mut s = Settings::default();
set_slider(Opt::ChatDelay, &mut s, 0.0);
assert_eq!(s.file.double("chatDelay"), 0.0);
set_slider(Opt::ChatDelay, &mut s, 0.5);
assert!((s.file.double("chatDelay") - 3.0).abs() < 1e-9);
set_slider(Opt::ChatDelay, &mut s, 1.0);
assert!((s.file.double("chatDelay") - 6.0).abs() < 1e-9);
}
/// `Options.chatDelay`'s message: None at zero, else the delay to one decimal.
#[test]
fn the_chat_delay_slider_says_its_delay() {
let mut s = Settings::default();
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
s.file.set("chatDelay", crate::options_file::V::Double(2.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 2.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(6.0));
assert_eq!(chat_delay_label(&s).plain(), "Chat Delay: 6.0 second(s)");
s.file.set("chatDelay", crate::options_file::V::Double(0.0));
assert_eq!(chat_delay_label(&s).plain(), "options.chat.delay_none");
}
@@ -165,7 +165,7 @@ impl OptionsUi {
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
@@ -1,54 +0,0 @@
//! The small option value labels (`generic`, `percent`, `on_off`, `chat_px`,
//! `chat_delay_label`) and `Options.chatDelay`'s slider mapping.
use super::*;
pub(crate) fn generic(caption: &str, value: Text) -> Text {
Text::translate("options.generic_value", &[tr(caption), value])
}
pub(crate) fn on_off(v: bool) -> Text {
tr(if v { "options.on" } else { "options.off" })
}
pub(crate) fn chat_px(caption: &str, px: f32) -> Text {
Text::translate(
"options.pixel_value",
&[tr(caption), Text::literal((px as i32).to_string())],
)
}
pub(crate) fn percent(caption: &str, value: f32) -> Text {
Text::translate(
"options.percent_value",
&[
tr(caption),
Text::literal(((f64::from(value) * 100.0) as i32).to_string()),
],
)
}
/// `Options.chatDelay`'s message: `options.chat.delay_none` at zero, else
/// `options.chat.delay` with the delay to one decimal (`%.1f` seconds).
pub(crate) fn chat_delay_label(s: &Settings) -> Text {
let value = s.file.double("chatDelay") as f32;
if value <= 0.0 {
Text::translate("options.chat.delay_none", &[])
} else {
Text::translate_with_fallback(
"options.chat.delay",
"Chat Delay: %s second(s)",
&[Text::literal(format!("{value:.1}"))],
)
}
}
/// `IntRange(0, 60).toSliderValue` on the stored seconds (`xmap(v / 10.0)`):
/// the tenth-of-a-second step over the slider's 60.
pub(crate) fn kind(value: f64) -> Kind {
Kind::Slider(((value * 10.0).floor() / 60.0).clamp(0.0, 1.0) as f32)
}
/// `IntRange(0, 60).fromSliderValue` then `xmap`'s `/ 10.0`:
/// floor(map(v, 0, 1, 0, 61)), the last step capped at 60, in tenths.
pub(crate) fn from_slider(v: f32) -> f64 {
f64::from((v.clamp(0.0, 1.0) * 61.0).floor().min(60.0) / 10.0)
}
/// `Options.chatDelay.set`: the slider's value as the stored seconds.
pub(crate) fn set(s: &mut Settings, v: f32) {
s.file
.set("chatDelay", crate::options_file::V::Double(from_slider(v)));
}
@@ -1,25 +0,0 @@
//! The option screens' widget constructors: a 20-pixel button row and the
//! same widget turned inactive.
use super::*;
pub(crate) fn button(x: f32, y: f32, w: f32, label: Text, act: Act) -> Widget {
Widget {
x,
y,
w,
h: 20.0,
kind: WKind::Button {
label,
active: true,
act,
},
list: false,
row: 0,
}
}
pub(crate) fn inactive(mut w: Widget) -> Widget {
if let WKind::Button { active, .. } = &mut w.kind {
*active = false;
}
w
}
+10 -145
View File
@@ -304,16 +304,10 @@ enum Focus {
Button(usize),
}
#[derive(Clone, Debug)]
enum ConfirmAction {
Select(String),
Import(Vec<std::path::PathBuf>),
}
/// The open `ConfirmScreen` for an incompatible pack or dropped files.
/// The open `ConfirmScreen` for an incompatible pack.
#[derive(Clone, Debug)]
struct Confirm {
action: ConfirmAction,
id: String,
title: Text,
message: Text,
}
@@ -322,8 +316,6 @@ struct Confirm {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
None,
/// The player accepted `pack.dropConfirm`.
Import(Vec<std::path::PathBuf>),
/// The Open Pack Folder button.
OpenFolder,
/// The Done button (`onClose`: commit).
@@ -803,45 +795,11 @@ impl PackScreen {
l.focused = false;
}
self.confirm = Some(Confirm {
action: ConfirmAction::Select(id.to_owned()),
id: id.to_owned(),
title: tr("pack.incompatible.confirm.title"),
message: tr(&format!("pack.incompatible.confirm.{}", compat.key())),
});
}
/// Winit delivers one event per path; collect a multi-file drop in one confirmation.
pub fn drop_file(&mut self, path: std::path::PathBuf) {
if matches!(
self.confirm.as_ref().map(|c| &c.action),
Some(ConfirmAction::Select(_))
) {
return;
}
let mut paths = match self.confirm.take() {
Some(Confirm {
action: ConfirmAction::Import(paths),
..
}) => paths,
_ => Vec::new(),
};
if !paths.contains(&path) {
paths.push(path);
}
let names = paths
.iter()
.filter_map(|p| p.file_name())
.map(|n| n.to_string_lossy())
.collect::<Vec<_>>()
.join(", ");
self.set_focus(Focus::None);
for list in &mut self.lists {
list.focused = false;
}
self.confirm = Some(Confirm {
action: ConfirmAction::Import(paths),
title: tr("pack.dropConfirm"),
message: Text::literal(names),
});
}
/// The ConfirmScreen's layout: title, message lines and the two buttons.
fn confirm_layout(
&self,
@@ -876,17 +834,12 @@ impl PackScreen {
// The callback: back to the pack screen (repositioned), then select.
self.confirm = None;
self.reposition(m);
if i == 0 {
match c.action {
ConfirmAction::Import(paths) => return Action::Import(paths),
ConfirmAction::Select(id) => {
if let Some(index) = self.model_index(0, &id) {
self.model.activate(false, index, true);
self.changed = true;
self.populate(Some(&id));
}
}
}
if i == 0
&& let Some(index) = self.model_index(0, &c.id)
{
self.model.activate(false, index, true);
self.changed = true;
self.populate(Some(&c.id));
}
return Action::None;
}
@@ -1765,19 +1718,6 @@ impl PackScreen {
[32, 2],
);
if l.scrollable() {
// AbstractScrollArea.extractScrollbar: over the bar, the pointer
// points, or resizes while the bar is held.
let over_bar = mouse.0 >= l.scroll_bar_x()
&& mouse.0 < l.scroll_bar_x() + 6
&& mouse.1 >= l.y
&& mouse.1 < l.bottom();
if over_bar {
crate::widgets::request_cursor(if l.held.is_some() {
crate::widgets::Cursor::ResizeNs
} else {
crate::widgets::Cursor::PointingHand
});
}
let sh = l.scroller_height().clamp(32, l.height - 8);
let sy = (l.scroll as i32 * (l.height - sh) / l.max_scroll() + l.y).max(l.y);
d::sprite(
@@ -1797,58 +1737,6 @@ impl PackScreen {
mod icon_tests {
use super::*;
#[test]
fn dropped_files_require_yes_and_keep_the_pending_selection() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let mut screen = PackScreen::new(
&m,
tr("resourcePack.title"),
480,
270,
false,
vec![pack("file/Keep", None)],
&[],
);
screen.model.activate(false, 0, true);
screen.changed = true;
let before = screen.commit();
assert_eq!(before, ["file/Keep"]);
screen.drop_file("One.zip".into());
screen.drop_file("Two".into());
screen.drop_file("One.zip".into());
let no = screen
.confirm_layout(&m, screen.confirm.as_ref().unwrap())
.2[1];
assert_eq!(
screen.click(&m, f64::from(no.0 + 1), f64::from(no.1 + 1)),
Action::None
);
assert!(!screen.confirming());
assert_eq!(screen.commit(), before);
screen.drop_file("One.zip".into());
screen.drop_file("Two".into());
screen.drop_file("One.zip".into());
let yes = screen
.confirm_layout(&m, screen.confirm.as_ref().unwrap())
.2[0];
assert_eq!(
screen.click(&m, f64::from(yes.0 + 1), f64::from(yes.1 + 1)),
Action::Import(vec!["One.zip".into(), "Two".into()])
);
assert_eq!(screen.commit(), before);
screen.drop_file("Cancelled.zip".into());
assert!(screen.cancel_confirm(&m));
assert!(!screen.confirming());
let mut options = crate::options_ui::OptionsUi::default();
options.pack_screen = Some(screen);
options.update_pack_infos(vec![pack("file/Keep", None), pack("file/Imported", None)]);
assert_eq!(options.pack_selection, ["file/Keep"]);
assert!(options.pack_screen.is_none());
}
fn png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
@@ -2100,7 +1988,7 @@ mod click_sound_tests {
);
}
screen.confirm = Some(Confirm {
action: ConfirmAction::Select("incompatible".into()),
id: "incompatible".into(),
title: Text::literal("Confirm"),
message: Text::literal("Use this pack?"),
});
@@ -2309,29 +2197,6 @@ mod icon_slot_tests {
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
/// `AbstractScrollArea.extractScrollbar`: over a held pack list's bar the
/// pointer asks to resize vertically, at rest it points.
#[test]
fn the_pack_scrollbar_asks_for_its_cursor() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 30, 200);
let l = &screen.lists[0];
assert!(l.scrollable());
let at = (l.scroll_bar_x() + 2, l.y + 8);
let _ = crate::widgets::take_cursor();
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
screen.lists[0].held = Some(f64::from(at.1));
screen.draw(&m, at, 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::ResizeNs
);
}
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
+1 -6
View File
@@ -4,11 +4,9 @@
//! and an overlay resource root that layers the enabled packs over the
//! vanilla resources (later packs win), so every loader sees one tree.
mod cache;
mod import;
use crate::text::Style;
use crate::{text::Text, zip::Zip};
pub use horizon_client_core::pattern;
pub use import::copy_pack;
use pattern::Pattern;
use std::{
collections::{BTreeMap, BTreeSet},
@@ -662,10 +660,7 @@ pub fn folder(dir: &Path, format: (i64, i64)) -> Vec<Pack> {
let name = e.file_name().to_string_lossy().into_owned();
let source = if path.is_dir() {
Source::Dir(path)
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
{
} else if name.ends_with(".zip") {
Source::Zip(path)
} else {
return None;
@@ -1,182 +0,0 @@
//! `PackSelectionScreen.copyPacks`: copy a dropped pack without replacing an existing one.
use std::{fs, io, path::Path};
fn invalid() -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, "not a regular resource pack")
}
fn copy_tree(from: &Path, to: &Path) -> io::Result<()> {
let mut directories = vec![(from.to_path_buf(), to.to_path_buf())];
while let Some((from, to)) = directories.pop() {
for entry in fs::read_dir(from)? {
let entry = entry?;
let kind = entry.file_type()?;
let target = to.join(entry.file_name());
if kind.is_symlink() {
return Err(invalid());
} else if kind.is_dir() {
fs::create_dir(&target)?;
directories.push((entry.path(), target));
} else if kind.is_file() {
fs::copy(entry.path(), target)?;
} else {
return Err(invalid());
}
}
}
Ok(())
}
/// Copies a zip or a folder with root `pack.mcmeta` into `dir`. Existing entries are
/// never overwritten; a failed copy removes only the destination this call created.
pub fn copy_pack(from: &Path, dir: &Path) -> io::Result<()> {
let kind = fs::symlink_metadata(from)?.file_type();
if kind.is_symlink() {
return Err(invalid());
}
if kind.is_dir() {
if !from.join("pack.mcmeta").is_file() {
return Err(invalid());
}
} else if !kind.is_file()
|| !from
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|| !crate::zip::Zip::open(from)?.contains("pack.mcmeta")
{
return Err(invalid());
}
let name = from.file_name().ok_or_else(invalid)?;
fs::create_dir_all(dir)?;
// A folder dropped onto its own descendant must not recursively copy itself.
if kind.is_dir() && dir.canonicalize()?.starts_with(from.canonicalize()?) {
return Err(invalid());
}
let target = dir.join(name);
let result = if kind.is_dir() {
fs::create_dir(&target)?;
copy_tree(from, &target)
} else {
let mut source = fs::File::open(from)?;
let mut output = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)?;
io::copy(&mut source, &mut output).map(|_| ())
};
if result.is_err() {
let cleanup = if kind.is_dir() {
fs::remove_dir_all(&target)
} else {
fs::remove_file(&target)
};
if let Err(error) = cleanup {
log::warn!(
"could not remove incomplete resource pack {}: {error}",
target.display()
);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn scratch(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("horizon-drop-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn folder(path: &Path) {
fs::create_dir_all(path.join("assets/example")).unwrap();
fs::write(
path.join("pack.mcmeta"),
br#"{"pack":{"pack_format":1,"description":"Test"}}"#,
)
.unwrap();
fs::write(path.join("assets/example/test.txt"), b"texture").unwrap();
}
#[test]
fn imports_folders_and_zips_that_discovery_can_list_without_enabling_them() {
let root = scratch("valid");
let pack = root.join("My Pack");
folder(&pack);
let zip_path = root.join("Other.ZIP");
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(&fs::read(pack.join("pack.mcmeta")).unwrap())
.unwrap();
zip.finish().unwrap();
let dir = root.join("resourcepacks");
copy_pack(&pack, &dir).unwrap();
copy_pack(&zip_path, &dir).unwrap();
assert_eq!(
copy_pack(&zip_path, &dir).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
assert_eq!(
fs::read(dir.join("My Pack/assets/example/test.txt")).unwrap(),
b"texture"
);
assert_eq!(
fs::read(dir.join("Other.ZIP")).unwrap(),
fs::read(&zip_path).unwrap()
);
let packs = super::super::folder(&dir, super::super::RESOURCE_FORMAT);
assert_eq!(
packs.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
["file/My Pack", "file/Other.ZIP"]
);
assert!(super::super::enabled(&packs, &[]).is_empty());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_inputs_and_collisions_leave_existing_files_untouched() {
let root = scratch("refused");
let dir = root.join("resourcepacks");
let pack = root.join("Pack");
folder(&pack);
copy_pack(&pack, &dir).unwrap();
fs::write(pack.join("assets/example/test.txt"), b"replacement").unwrap();
assert_eq!(
copy_pack(&pack, &dir).unwrap_err().kind(),
io::ErrorKind::AlreadyExists
);
assert_eq!(
fs::read(dir.join("Pack/assets/example/test.txt")).unwrap(),
b"texture"
);
fs::write(root.join("bad.zip"), b"not a zip").unwrap();
fs::create_dir(root.join("empty")).unwrap();
assert!(copy_pack(&root.join("bad.zip"), &dir).is_err());
assert!(copy_pack(&root.join("empty"), &dir).is_err());
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1);
assert!(copy_pack(&pack, &pack.join("nested")).is_err());
assert!(!pack.join("nested/Pack").exists());
fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn refuses_symlinks_and_removes_its_partial_copy() {
let root = scratch("symlink");
let pack = root.join("Pack");
folder(&pack);
std::os::unix::fs::symlink(&pack, pack.join("loop")).unwrap();
let dir = root.join("resourcepacks");
assert!(copy_pack(&pack, &dir).is_err());
assert!(!dir.join("Pack").exists());
assert!(pack.join("pack.mcmeta").exists());
std::os::unix::fs::symlink(&pack, root.join("linked")).unwrap();
assert!(copy_pack(&root.join("linked"), &dir).is_err());
fs::remove_dir_all(root).unwrap();
}
}
+20 -42
View File
@@ -3825,7 +3825,7 @@ impl ParticleEngine {
}
let pending = std::mem::take(&mut self.pending);
for mut p in pending {
if !self.polytone_joins(&p, level) {
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
continue;
}
if p.pre_tick {
@@ -4693,19 +4693,20 @@ impl ParticleEngine {
.collect()
}
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
pub fn render(
&self,
camera: DVec3,
base: Quat,
yaw: f32,
pitch: f32,
partial: f32,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
base,
yaw,
pitch,
partial,
0.0,
None,
@@ -4720,7 +4721,8 @@ impl ParticleEngine {
pub fn render_culled(
&self,
camera: DVec3,
base: Quat,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
culling_enabled: bool,
@@ -4730,12 +4732,13 @@ impl ParticleEngine {
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, base, partial, light_at, out);
self.render(camera, yaw, pitch, partial, light_at, out);
return;
}
self.render_inner::<true>(
camera,
base,
yaw,
pitch,
partial,
pixel_focal_length,
frustum,
@@ -4748,7 +4751,8 @@ impl ParticleEngine {
fn render_inner<const CULL: bool>(
&self,
camera: DVec3,
base: Quat,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
frustum: Option<&crate::gfx::Frustum>,
@@ -4756,6 +4760,9 @@ impl ParticleEngine {
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
additive.clear();
@@ -5166,11 +5173,12 @@ mod tests {
let light_at = |_: [i32; 3]| 0xf0;
let mut regular = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
let mut disabled = Vec::new();
engine.render_culled(
DVec3::ZERO,
Quat::IDENTITY,
0.0,
0.0,
0.5,
900.0,
false,
@@ -5199,36 +5207,6 @@ mod tests {
assert_eq!(attributes(&disabled), attributes(&regular));
}
#[test]
fn quads_face_the_rotation_they_are_given() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
engine.atlas.insert(sprite.clone(), (rect, false));
let mut random = LegacyRandom::new(7);
let mut particle =
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
particle.sprite = Sprite::Named(sprite);
engine.particles.push(particle);
let draw = |rotation: Quat| {
let mut out = Vec::new();
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
out.iter().map(|v| v.pos).collect::<Vec<_>>()
};
// A bed's view turns the quads as the player's own yaw would not.
let ahead = draw(Quat::IDENTITY);
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
assert_eq!(ahead.len(), 4);
assert_ne!(ahead, turned);
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
@@ -5622,7 +5600,7 @@ mod tests {
particle.sprite = Sprite::Index(0);
engine.particles.push(particle);
let mut vertices = Vec::new();
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
@@ -143,39 +143,21 @@ mod tests {
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(
DVec3::ZERO,
glam::Quat::IDENTITY,
0.5,
&|_| 0,
&mut vertices,
);
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
Loaded 100 of 665 files, more files were not shown because too many files have changed in this diff. Show more