mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 17:19:31 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad777a0249 | ||
|
|
3533ab20f5 | ||
|
|
79cfbfc950 | ||
|
|
a82091736e | ||
|
|
40637018fa | ||
|
|
c48cdddd66 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(®ular));
|
||||
}
|
||||
|
||||
#[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
Reference in new issue
Block a user