mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-13 04:33:48 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e476f2323a | ||
|
|
d2c25b65b3 | ||
|
|
1a1d60e77f | ||
|
|
4ded09f53d | ||
|
|
c444303d55 | ||
|
|
e32ef2f8ee | ||
|
|
90d0076f43 | ||
|
|
39b9db52c4 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
# `cargo xtask …` runs the repository's task crate, so the commands in docs/perf-platform.md and
|
||||
# web/perf/README.md work as written. `cargo run -p xtask -- …` stays equivalent.
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -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'
|
||||
|
||||
+12
-16
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: perf-wasm
|
||||
# The site consumes this reader bundle from horizon-rust/horizon-site.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-perf/**'
|
||||
- 'crates/horizon-perf-format/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'xtask/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.github/workflows/perf-wasm.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'crates/horizon-perf/**'
|
||||
- 'crates/horizon-perf-format/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'xtask/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.github/workflows/perf-wasm.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-wasm-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-wasm
|
||||
# The agreement test: the C ABI, called natively, must print the bytes the CLI prints.
|
||||
- name: The wasm ABI agrees with the CLI
|
||||
run: cargo test -p horizon-perf --features wasm --test wasm
|
||||
- name: Build the wasm the page loads
|
||||
run: cargo run -p xtask -- perf-wasm
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-wasm
|
||||
path: target/perf-wasm
|
||||
if-no-files-found: error
|
||||
@@ -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
|
||||
@@ -91,7 +98,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the `hub` feature runs them on the engine's hook (`lan/watch.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
|
||||
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
@@ -102,7 +109,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-perf-format` | The `.hzperf` capture contract ([docs/perf-platform.md](docs/perf-platform.md) §3): `proto/horizon/perf/v1/capture.proto`, its generated types, the gzip writer and its thread. No Horizon dependencies; nothing in the game reads a capture |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
|
||||
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
|
||||
@@ -129,8 +135,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
|
||||
@@ -139,8 +145,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/`,
|
||||
@@ -207,7 +212,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (its `hub` feature; L: `lan/watch.rs`, the hook) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| 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` |
|
||||
|
||||
+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
-390
@@ -18,15 +18,6 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -138,12 +129,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.7.0"
|
||||
@@ -279,12 +264,6 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "beef"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -580,7 +559,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width 0.2.2",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -727,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"
|
||||
@@ -966,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"
|
||||
@@ -1032,12 +986,6 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1136,18 +1084,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "framehop"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f54fe4785e899d4d6f43793b151c63c5647240fc630b005509d2614a939f693"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cfg-if",
|
||||
"fallible-iterator",
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
@@ -1285,15 +1221,6 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -1455,7 +1382,13 @@ version = "0.1.0"
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-client-lib",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
@@ -1474,7 +1407,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1530,13 +1463,10 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1599,6 +1529,7 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
@@ -1611,8 +1542,6 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1631,7 +1560,6 @@ dependencies = [
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1662,13 +1590,12 @@ dependencies = [
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1886,7 +1813,6 @@ dependencies = [
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-perf-share",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
@@ -1905,60 +1831,18 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-bench",
|
||||
"horizon-perf-format",
|
||||
"horizon-perf-share",
|
||||
"prost",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf-format"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"protox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf-share"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"horizon-core",
|
||||
"log",
|
||||
"open",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
"url",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-hooks",
|
||||
"horizon-perf-format",
|
||||
"horizon-prepare",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -2043,6 +1927,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-mcp",
|
||||
"horizon-platform",
|
||||
@@ -2126,14 +2011,9 @@ version = "0.1.0"
|
||||
name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"framehop",
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"memmap2",
|
||||
"mimalloc",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2143,7 +2023,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"horizon-threads",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
@@ -2385,15 +2264,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2620,72 +2490,6 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
|
||||
dependencies = [
|
||||
"logos-derive 0.15.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
|
||||
dependencies = [
|
||||
"logos-derive 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
|
||||
dependencies = [
|
||||
"beef",
|
||||
"fnv",
|
||||
"lazy_static",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-syntax",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
|
||||
dependencies = [
|
||||
"logos-codegen 0.15.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
|
||||
dependencies = [
|
||||
"logos-codegen 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.5"
|
||||
@@ -2728,28 +2532,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"miette-derive",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette-derive"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
@@ -2803,12 +2585,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3389,15 +3165,6 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -3729,16 +3496,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -3763,96 +3520,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-build"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"regex",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-reflect"
|
||||
version = "0.16.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
|
||||
dependencies = [
|
||||
"logos 0.16.1",
|
||||
"miette",
|
||||
"prost",
|
||||
"prost-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protox"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"miette",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
"protox-parse",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protox-parse"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366"
|
||||
dependencies = [
|
||||
"logos 0.15.1",
|
||||
"miette",
|
||||
"prost-types",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -4004,26 +3671,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"
|
||||
@@ -4152,12 +3799,6 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4716,18 +4357,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -4997,12 +4626,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -37,9 +37,6 @@ members = [
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-perf-format",
|
||||
"crates/horizon-perf",
|
||||
"crates/horizon-perf-share",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mods",
|
||||
|
||||
@@ -35,12 +35,6 @@ pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
|
||||
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
|
||||
|
||||
/// The performance viewer page a saved capture opens in (`HORIZON_PERF_VIEWER` overrides it).
|
||||
pub const PERF_VIEWER: &str = "https://playhorizon.dev/perf";
|
||||
|
||||
/// The capture upload API behind Share (`HORIZON_PERF_CAPTURES` overrides it).
|
||||
pub const PERF_CAPTURES: &str = "https://playhorizon.dev/api/perf/captures";
|
||||
|
||||
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
|
||||
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
|
||||
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
|
||||
|
||||
@@ -6,9 +6,28 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[features]
|
||||
# The checks on the game's server hub (`hub`): the engine's hook (`lan::watch`) filled. Without it,
|
||||
# only the settings and the rules, which horizon-server reads.
|
||||
hub = [
|
||||
"dep:horizon-client-lib",
|
||||
"dep:horizon-core",
|
||||
"dep:horizon-protocol",
|
||||
"dep:horizon-world",
|
||||
"dep:chrono",
|
||||
"dep:serde_json",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "1.1"
|
||||
# The `hub` feature. The engine's own features stay the game's (`default-features = false`).
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false, optional = true }
|
||||
horizon-core = { path = "../horizon-core", optional = true }
|
||||
horizon-protocol = { path = "../horizon-protocol", optional = true }
|
||||
horizon-world = { path = "../horizon-world", optional = true }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"], optional = true }
|
||||
serde_json = { version = "1.0.151", optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+178
-248
@@ -1,20 +1,23 @@
|
||||
//! The anticheat on a dedicated server's hub (docs/anticheat.md): the checks run where the hub handles
|
||||
//! what players send, and a flag is carried out here (send back, alert, kick, or a ban the game adds to
|
||||
//! `banned-players.json`: [`HostEvent::Ban`]). The rules and the
|
||||
//! violation levels are `horizon_anticheat`'s. A dedicated server has it on by default; the hub holds
|
||||
//! none of this when `server.toml` turns it off (or for a LAN world), and then does exactly what vanilla's
|
||||
//! server does.
|
||||
use super::hub::{HostEvent, Hub, TickCtx};
|
||||
use crate::collisions::SceneCollisions;
|
||||
use horizon_anticheat::{Check, Config, Strikes};
|
||||
//! The anticheat on a dedicated server's hub (docs/anticheat.md), the `hub` feature: the checks run
|
||||
//! through the engine's hook (`lan::watch`) where the hub handles what players send, and a flag is
|
||||
//! carried out here (send back, alert, kick, or a ban the game adds to `banned-players.json`:
|
||||
//! [`HostEvent::Ban`]). The rules and the violation levels are this crate's. A dedicated server has it on
|
||||
//! by default; the hub holds none of this when `server.toml` turns it off (or for a LAN
|
||||
//! world), and then does exactly what vanilla's server does.
|
||||
use crate::{Check, Config, Player, Strikes};
|
||||
use horizon_client_lib::collisions::SceneCollisions;
|
||||
use horizon_client_lib::lan::guest::Guest;
|
||||
use horizon_client_lib::lan::hub::{HostEvent, Hub, TickCtx};
|
||||
use horizon_client_lib::lan::prediction::Over;
|
||||
use horizon_client_lib::lan::watch::Watch;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{io::Write, time::Instant};
|
||||
|
||||
pub struct Anticheat {
|
||||
pub config: Config,
|
||||
/// The thread writing `log`, started at the first flag: the tick only hands it the lines.
|
||||
log: Option<LogWriter>,
|
||||
/// `log`, opened at the first flag.
|
||||
log: Option<std::fs::File>,
|
||||
/// The players' kicks by check, read from `strikes` at the first kick.
|
||||
strikes: Option<Strikes>,
|
||||
/// When the previous server tick started, for the timer's balance.
|
||||
@@ -38,44 +41,19 @@ impl Anticheat {
|
||||
return;
|
||||
}
|
||||
if self.log.is_none() {
|
||||
let path = std::path::PathBuf::from(&self.config.log);
|
||||
let path = std::path::Path::new(&self.config.log);
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let Ok(file) = std::fs::OpenOptions::new()
|
||||
self.log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// A flood of flags (a lagging player, the load-test bots) wrote to the disk on the
|
||||
// tick thread, one line at a time: the lines go to a writer thread instead, in order.
|
||||
let (send, lines) = std::sync::mpsc::channel::<String>();
|
||||
let Ok(thread) = std::thread::Builder::new()
|
||||
.name("anticheat-log".into())
|
||||
.spawn(move || {
|
||||
// The lines waiting are written together, flushed once none is left.
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
while let Ok(entry) = lines.recv() {
|
||||
let _ = file.write_all(entry.as_bytes());
|
||||
for entry in lines.try_iter() {
|
||||
let _ = file.write_all(entry.as_bytes());
|
||||
}
|
||||
let _ = file.flush();
|
||||
}
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.log = Some(LogWriter {
|
||||
send: Some(send),
|
||||
thread: Some(thread),
|
||||
});
|
||||
.open(path)
|
||||
.ok();
|
||||
}
|
||||
if let Some(send) = self.log.as_ref().and_then(|log| log.send.as_ref()) {
|
||||
if let Some(file) = &mut self.log {
|
||||
let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
|
||||
let _ = send.send(format!("[{time}] {line}\n"));
|
||||
let _ = writeln!(file, "[{time}] {line}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,22 +78,6 @@ impl Anticheat {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag log's writer thread. Dropped with the anticheat (the server stopping), it closes the
|
||||
/// channel and waits for the thread to write every line handed to it, so none is lost.
|
||||
struct LogWriter {
|
||||
send: Option<std::sync::mpsc::Sender<String>>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for LogWriter {
|
||||
fn drop(&mut self) {
|
||||
self.send = None;
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A UUID as `banned-players.json` writes it.
|
||||
fn uuid_text(uuid: u128) -> String {
|
||||
let h = format!("{uuid:032x}");
|
||||
@@ -155,17 +117,33 @@ fn text(s: &str, color: &str) -> Tag {
|
||||
])
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Turn the anticheat on with these settings, or off (`config.enabled` false).
|
||||
pub fn set_anticheat(&mut self, config: Config) {
|
||||
self.anticheat = config.enabled.then(|| Box::new(Anticheat::new(config)));
|
||||
}
|
||||
/// Guest `g`'s violation levels and the checks' state, made at its first use.
|
||||
fn player(g: &mut Guest) -> &mut Player {
|
||||
g.anticheat
|
||||
.get_or_insert_with(|| Box::new(Player::default()))
|
||||
.downcast_mut()
|
||||
.expect("the guest's anticheat state is the anticheat's Player")
|
||||
}
|
||||
|
||||
/// The hub, while its hook runs the checks with the anticheat (`ac`) it lent.
|
||||
struct On<'a>(&'a mut Hub);
|
||||
|
||||
impl std::ops::Deref for On<'_> {
|
||||
type Target = Hub;
|
||||
fn deref(&self) -> &Hub {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for On<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Hub {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl On<'_> {
|
||||
/// The start of a server tick: the time since the previous one fills each player's timer balance.
|
||||
pub(super) fn anticheat_tick(&mut self, tick_rate: f32) {
|
||||
let Some(ac) = self.anticheat.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
fn anticheat_tick(&mut self, ac: &mut Anticheat, tick_rate: f32) {
|
||||
let now = Instant::now();
|
||||
let elapsed = ac.last_tick.replace(now).map(|t| now - t);
|
||||
if !ac.config.runs(Check::Timer) {
|
||||
@@ -177,36 +155,26 @@ impl Hub {
|
||||
let ms = elapsed.map_or(0.0, |e| e.as_secs_f64() * 1000.0);
|
||||
for g in &mut self.guests {
|
||||
if restart {
|
||||
g.anticheat.timer.reset();
|
||||
player(g).timer.reset();
|
||||
}
|
||||
g.anticheat.timer.elapse(ms, &ac.config.timer);
|
||||
player(g).timer.elapse(ms, &ac.config.timer);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: one client tick spent.
|
||||
pub(super) fn anticheat_tick_end(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return;
|
||||
};
|
||||
fn anticheat_tick_end(&mut self, ac: &mut Anticheat, i: usize, events: &mut Vec<HostEvent>) {
|
||||
if ac.config.runs(Check::Combat)
|
||||
&& let Some(foul) = self.guests[i].anticheat.attacks.tick_end()
|
||||
&& let Some(foul) = player(&mut self.guests[i]).attacks.tick_end()
|
||||
{
|
||||
self.combat_foul(i, foul, events);
|
||||
}
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if ac.config.runs(Check::Prediction) {
|
||||
let g = &mut self.guests[i];
|
||||
let (input, sprinting) = (g.input, g.client_sprinting);
|
||||
g.record.get_or_insert_default().tick_end(input, sprinting);
|
||||
self.combat_foul(ac, i, foul, events);
|
||||
}
|
||||
if !ac.config.runs(Check::Timer) {
|
||||
return;
|
||||
}
|
||||
let (rate, config) = (ac.tick_rate, &ac.config.timer);
|
||||
if let Some(ahead) = self.guests[i].anticheat.timer.tick_end(rate, config) {
|
||||
if let Some(ahead) = player(&mut self.guests[i]).timer.tick_end(rate, config) {
|
||||
self.flag(
|
||||
ac,
|
||||
i,
|
||||
Check::Timer,
|
||||
1.0,
|
||||
@@ -218,48 +186,38 @@ impl Hub {
|
||||
|
||||
/// ServerboundAttackPacket on entity `target`: `combat`. Returns whether the attack is dropped:
|
||||
/// from `setback` on, every attack is, until the level decays below it.
|
||||
pub(super) fn anticheat_attack(
|
||||
fn anticheat_attack(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
target: i32,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if !ac.config.runs(Check::Combat) {
|
||||
return false;
|
||||
}
|
||||
if let Some(foul) = self.guests[i].anticheat.attacks.attack(target)
|
||||
&& self.combat_foul(i, foul, events)
|
||||
if let Some(foul) = player(&mut self.guests[i]).attacks.attack(target)
|
||||
&& self.combat_foul(ac, i, foul, events)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let setback = ac.config.combat.setback;
|
||||
setback > 0.0
|
||||
&& self.guests[i]
|
||||
.anticheat
|
||||
.vl(&ac.config, Check::Combat, self.ticks)
|
||||
>= setback
|
||||
let (setback, ticks) = (ac.config.combat.setback, self.ticks);
|
||||
setback > 0.0 && player(&mut self.guests[i]).vl(&ac.config, Check::Combat, ticks) >= setback
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung, for `combat`.
|
||||
pub(super) fn anticheat_swing(&mut self, i: usize) {
|
||||
if self.anticheat.is_some() {
|
||||
self.guests[i].anticheat.attacks.swing();
|
||||
}
|
||||
fn anticheat_swing(&mut self, i: usize) {
|
||||
player(&mut self.guests[i]).attacks.swing();
|
||||
}
|
||||
|
||||
fn combat_foul(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
foul: horizon_anticheat::combat::Foul,
|
||||
foul: crate::combat::Foul,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
use horizon_anticheat::combat::Foul;
|
||||
use crate::combat::Foul;
|
||||
let detail = match foul {
|
||||
Foul::NoSwing(n) => {
|
||||
format!("attacked without swinging ({n} more attacks than swings in a tick)")
|
||||
@@ -268,70 +226,7 @@ impl Hub {
|
||||
format!("attacked entities {first} and {then} in one tick")
|
||||
}
|
||||
};
|
||||
self.flag(i, Check::Combat, 1.0, &detail, events)
|
||||
}
|
||||
|
||||
/// Whether the prediction records the players' client ticks.
|
||||
pub fn recording(&self) -> bool {
|
||||
self.anticheat
|
||||
.as_deref()
|
||||
.is_some_and(|ac| ac.config.runs(Check::Prediction))
|
||||
}
|
||||
|
||||
/// A move packet from guest `i`, as it came: the prediction's record of the client tick.
|
||||
pub(super) fn anticheat_moved(
|
||||
&mut self,
|
||||
i: usize,
|
||||
pos: Option<[f64; 3]>,
|
||||
rot: Option<(f32, f32)>,
|
||||
flags: u8,
|
||||
) {
|
||||
if self.recording() {
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
record.moved(pos, rot, flags);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket: the client has handled every packet of the server tick `id` (the ping
|
||||
/// [`Hub::anticheat_ping`] sent at its end).
|
||||
pub(super) fn anticheat_pong(&mut self, i: usize, id: i32) {
|
||||
let ticks = self.ticks;
|
||||
if self.recording() {
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
// Ids are server ticks, cut to 32 bits.
|
||||
record.pong(u64::from(id as u32), ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of a server tick, after everything it sent: a ping whose id is the tick to each
|
||||
/// player in the world, so the prediction knows from which client tick on that tick's packets
|
||||
/// were in effect.
|
||||
pub fn anticheat_ping(&mut self) {
|
||||
if !self.recording() {
|
||||
return;
|
||||
}
|
||||
// What the tick sent the players that changes their movement went out before this ping.
|
||||
let ticks = self.ticks;
|
||||
for g in &mut self.guests {
|
||||
let changes = std::mem::take(
|
||||
g.changes
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
);
|
||||
if let Some(record) = g.record.as_deref_mut() {
|
||||
for change in changes {
|
||||
record.changed(ticks, change);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ping = horizon_protocol::packets::play::Clientbound::Ping(
|
||||
horizon_protocol::packets::configuration::Ping {
|
||||
id: self.ticks as u32 as i32,
|
||||
},
|
||||
);
|
||||
for g in self.guests.iter().filter(|g| g.placed) {
|
||||
g.send(&ping);
|
||||
}
|
||||
self.flag(ac, i, Check::Combat, 1.0, &detail, events)
|
||||
}
|
||||
|
||||
/// `ground`, for a move from guest `i` that says it ends on the ground at `to` (`stands`: the move
|
||||
@@ -341,15 +236,17 @@ impl Hub {
|
||||
/// move doesn't count toward vanilla's floating kick. A claim at the height where the
|
||||
/// ground last held the player, within half a second, isn't judged: the block may have gone
|
||||
/// server-side (broken, pushed) before the client heard.
|
||||
pub(super) fn anticheat_ground(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn anticheat_ground(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool> {
|
||||
if !self.anticheat.as_deref()?.config.runs(Check::Ground) {
|
||||
if !ac.config.runs(Check::Ground) {
|
||||
return None;
|
||||
}
|
||||
// Standing still: no fall to skip, whatever holds the player up (a block the server doesn't
|
||||
@@ -364,10 +261,10 @@ impl Hub {
|
||||
let from = self.guests[i].position;
|
||||
let held_at_start = || self.ground_holds(i, [from[0], to[1], from[2]], ctx) != Some(false);
|
||||
if stands || self.ground_holds(i, to, ctx)? || held_at_start() {
|
||||
self.guests[i].anticheat.ground = Some((ticks, to[1]));
|
||||
player(&mut self.guests[i]).ground = Some((ticks, to[1]));
|
||||
return None;
|
||||
}
|
||||
if self.guests[i].anticheat.ground.is_some_and(|(t, y)| {
|
||||
if player(&mut self.guests[i]).ground.is_some_and(|(t, y)| {
|
||||
ticks.saturating_sub(t) <= GROUND_GRACE && (y - to[1]).abs() < 1.0E-3
|
||||
}) {
|
||||
return None;
|
||||
@@ -385,8 +282,8 @@ impl Hub {
|
||||
"claims the ground {below} at {:.1} {:.1} {:.1}",
|
||||
to[0], to[1], to[2]
|
||||
);
|
||||
self.flag(i, Check::Ground, 1.0, &detail, events);
|
||||
if self.anticheat.as_deref()?.config.ground.setback == 0.0 {
|
||||
self.flag(ac, i, Check::Ground, 1.0, &detail, events);
|
||||
if ac.config.ground.setback == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The correction is the dropped claim, never a setback: sent back into the air it claimed to
|
||||
@@ -451,23 +348,25 @@ impl Hub {
|
||||
|
||||
/// `check` failed for guest `i`: raise its level, log it, and act on it. Returns whether the move or
|
||||
/// action is cancelled (the player is sent back, or kicked).
|
||||
pub(super) fn flag(
|
||||
fn flag(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
check: Check,
|
||||
weight: f32,
|
||||
detail: &str,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
self.flag_to(i, check, weight, detail, events, None)
|
||||
self.flag_to(ac, i, check, weight, detail, events, None)
|
||||
}
|
||||
|
||||
/// `prediction`: guest `conn`'s ticks that landed off the replay. A setback sends the player
|
||||
/// back to `agreed`, where its moves last agreed.
|
||||
pub fn anticheat_prediction(
|
||||
fn anticheat_prediction(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
conn: u32,
|
||||
over: &super::prediction::Over,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent> {
|
||||
let mut events = Vec::new();
|
||||
@@ -483,6 +382,7 @@ impl Hub {
|
||||
);
|
||||
let weight = over.weight;
|
||||
self.flag_to(
|
||||
ac,
|
||||
i,
|
||||
Check::Prediction,
|
||||
weight,
|
||||
@@ -494,9 +394,11 @@ impl Hub {
|
||||
events
|
||||
}
|
||||
|
||||
/// [`Hub::flag`], whose setback sends the player to `back` (where it was by default).
|
||||
/// [`On::flag`], whose setback sends the player to `back` (where it was by default).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flag_to(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
check: Check,
|
||||
weight: f32,
|
||||
@@ -505,16 +407,13 @@ impl Hub {
|
||||
back: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
let ticks = self.ticks;
|
||||
let Some(ac) = self.anticheat.as_deref_mut() else {
|
||||
return false;
|
||||
};
|
||||
// Kicked or banned already, and leaving: what it still had queued (a lagging client's ticks)
|
||||
// is dropped, and counts no second strike in the session the first kick warned.
|
||||
if !self.guests[i].left_reason.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let g = &mut self.guests[i];
|
||||
let v = g.anticheat.flag(&ac.config, check, weight, ticks);
|
||||
let v = player(g).flag(&ac.config, check, weight, ticks);
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
let ban_after = ac.config.actions(check).ban_after;
|
||||
let (kicks, ban) = if v.kick {
|
||||
@@ -578,7 +477,8 @@ impl Hub {
|
||||
reason: format!("Horizon anticheat: {}", check.name()),
|
||||
});
|
||||
} else if v.kick {
|
||||
self.anticheat_kick(self.guests[i].conn);
|
||||
let conn = self.guests[i].conn;
|
||||
self.anticheat_kick(conn);
|
||||
} else if v.setback
|
||||
&& !matches!(check, Check::Combat | Check::Ground)
|
||||
&& self.guests[i].awaiting_teleport.is_none()
|
||||
@@ -589,10 +489,100 @@ impl Hub {
|
||||
}
|
||||
v.setback || v.kick
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnects `conn` for the anticheat.
|
||||
pub fn anticheat_kick(&mut self, conn: u32) {
|
||||
self.kick(conn, text("Kicked by the server's anticheat", "white"));
|
||||
/// What [`load`] gives: the anticheat (`None`: off) and the lines to log, as (level, line).
|
||||
#[derive(Default)]
|
||||
pub struct Loaded {
|
||||
pub watch: Option<Box<dyn Watch>>,
|
||||
pub log: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// The dedicated server's anticheat from `server.toml`'s `[anticheat]` (`None`: no file): the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
pub fn load(server_toml: Option<&str>) -> Loaded {
|
||||
let mut loaded = Loaded::default();
|
||||
let config = match server_toml.map(Config::from_server_toml) {
|
||||
None => Config::default(),
|
||||
Some(Ok(config)) => {
|
||||
if config.enabled {
|
||||
loaded.log.push((
|
||||
"INFO",
|
||||
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)".into(),
|
||||
));
|
||||
}
|
||||
config
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
loaded.log.push((
|
||||
"ERROR",
|
||||
format!("server.toml: {e}; the anticheat stays off"),
|
||||
));
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
loaded.watch = watch(config);
|
||||
loaded
|
||||
}
|
||||
|
||||
/// The hub's anticheat with these settings; none when they turn it off.
|
||||
pub fn watch(config: Config) -> Option<Box<dyn Watch>> {
|
||||
config
|
||||
.enabled
|
||||
.then(|| Box::new(Anticheat::new(config)) as Box<dyn Watch>)
|
||||
}
|
||||
|
||||
impl Watch for Anticheat {
|
||||
fn records(&self) -> bool {
|
||||
self.config.runs(Check::Prediction)
|
||||
}
|
||||
|
||||
fn tolerance(&self) -> f64 {
|
||||
self.config.prediction.tolerance
|
||||
}
|
||||
|
||||
fn tick(&mut self, hub: &mut Hub, tick_rate: f32) {
|
||||
On(hub).anticheat_tick(self, tick_rate);
|
||||
}
|
||||
|
||||
fn tick_end(&mut self, hub: &mut Hub, i: usize, events: &mut Vec<HostEvent>) {
|
||||
On(hub).anticheat_tick_end(self, i, events);
|
||||
}
|
||||
|
||||
fn attack(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
i: usize,
|
||||
target: i32,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
On(hub).anticheat_attack(self, i, target, events)
|
||||
}
|
||||
|
||||
fn swing(&mut self, hub: &mut Hub, i: usize) {
|
||||
On(hub).anticheat_swing(i);
|
||||
}
|
||||
|
||||
fn ground(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool> {
|
||||
On(hub).anticheat_ground(self, i, to, stands, ctx, events)
|
||||
}
|
||||
|
||||
fn prediction(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
conn: u32,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent> {
|
||||
On(hub).anticheat_prediction(self, conn, over, agreed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,66 +590,6 @@ impl Hub {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The log's lines reach the file in the order flagged, every one of them by the time the
|
||||
/// anticheat is dropped (the server stopping), even a flood written just before.
|
||||
#[test]
|
||||
fn the_log_keeps_every_line_in_order_up_to_the_drop() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-anticheat-log-{}", std::process::id()));
|
||||
let path = dir.join("logs").join("anticheat.log");
|
||||
let mut anticheat = Anticheat::new(Config {
|
||||
log: path.to_string_lossy().into_owned(),
|
||||
..Config::default()
|
||||
});
|
||||
for i in 0..20_000 {
|
||||
anticheat.write_log(&format!("flag {i}"));
|
||||
}
|
||||
drop(anticheat);
|
||||
let written = std::fs::read_to_string(&path).expect("the log was written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let flags: Vec<&str> = written
|
||||
.lines()
|
||||
.map(|line| line.split_once("] ").expect("a timestamp").1)
|
||||
.collect();
|
||||
assert_eq!(flags.len(), 20_000);
|
||||
assert!(
|
||||
flags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, f)| *f == format!("flag {i}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Dropping the writer waits for its thread to write what it was handed, however far behind
|
||||
/// it is.
|
||||
#[test]
|
||||
fn dropping_the_writer_waits_for_its_lines() {
|
||||
let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let (send, lines) = std::sync::mpsc::channel::<String>();
|
||||
let out = std::sync::Arc::clone(&written);
|
||||
let thread = std::thread::spawn(move || {
|
||||
for line in lines {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
out.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
let writer = LogWriter {
|
||||
send: Some(send),
|
||||
thread: Some(thread),
|
||||
};
|
||||
for i in 0..5 {
|
||||
writer
|
||||
.send
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send(format!("flag {i}"))
|
||||
.unwrap();
|
||||
}
|
||||
drop(writer);
|
||||
let expected: Vec<String> = (0..5).map(|i| format!("flag {i}")).collect();
|
||||
assert_eq!(*written.lock().unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuids_are_written_as_the_ban_list_writes_them() {
|
||||
assert_eq!(
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//! (`horizon_client::lan`) feeds it what players send, through the engine's hook the `hub` feature fills,
|
||||
//! and carries out what it decides.
|
||||
//!
|
||||
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
|
||||
//! their own (`combat`); the others log only until they have shown no false positive with real
|
||||
@@ -21,6 +22,8 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
#[cfg(feature = "hub")]
|
||||
pub mod hub;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
|
||||
@@ -1764,13 +1764,14 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
.iter()
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(entries, needle) {
|
||||
match binary_search(&haystack, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1779,8 +1780,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1790,7 +1791,7 @@ fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Res
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = entries[mid as usize].0;
|
||||
let value = a[mid as usize];
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,61 +158,3 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -9,7 +9,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes (cores, clocks, drive, VRAM, power, form); a reader of reports (the wasm
|
||||
# build of horizon-perf) needs the types below, never the probes.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -1,434 +0,0 @@
|
||||
//! Paired A/B comparison of benchmark blocks (docs/benchmark-report.md, "A/B runs").
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{round, Frames};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
TooFewPairs,
|
||||
WithinNoise,
|
||||
Negligible,
|
||||
Slower,
|
||||
Faster,
|
||||
}
|
||||
|
||||
/// B against A over paired blocks; lower milliseconds are better.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Paired {
|
||||
pub pairs: usize,
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
/// Hodges-Lehmann estimate of b - a.
|
||||
pub diff: f64,
|
||||
/// Absent when `a` is zero.
|
||||
pub diff_pct: Option<f64>,
|
||||
/// Exact Wilcoxon signed-rank interval; absent below 6 pairs.
|
||||
pub ci95: Option<[f64; 2]>,
|
||||
pub confidence: Option<f64>,
|
||||
/// Median distance between neighbouring blocks of the same arm.
|
||||
pub noise: Option<f64>,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
type Getter = fn(&Frames) -> Option<f64>;
|
||||
|
||||
fn median(values: &[f64]) -> f64 {
|
||||
let mut v = values.to_vec();
|
||||
v.sort_by(f64::total_cmp);
|
||||
match v.len() {
|
||||
0 => 0.0,
|
||||
n if n % 2 == 1 => v[n / 2],
|
||||
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn round4(v: f64) -> f64 {
|
||||
(v * 10_000.0).round() / 10_000.0
|
||||
}
|
||||
|
||||
/// `(a, b)` per pair, `noise` left for the caller.
|
||||
pub fn paired(pairs: &[(f64, f64)]) -> Paired {
|
||||
let n = pairs.len();
|
||||
let a = median(&pairs.iter().map(|p| p.0).collect::<Vec<_>>());
|
||||
let b = median(&pairs.iter().map(|p| p.1).collect::<Vec<_>>());
|
||||
let d: Vec<f64> = pairs.iter().map(|p| p.1 - p.0).collect();
|
||||
let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
|
||||
for i in 0..n {
|
||||
for j in i..n {
|
||||
walsh.push((d[i] + d[j]) / 2.0);
|
||||
}
|
||||
}
|
||||
walsh.sort_by(f64::total_cmp);
|
||||
let diff = median(&walsh);
|
||||
let interval = interval(n, &walsh);
|
||||
let verdict = match interval {
|
||||
None => Verdict::TooFewPairs,
|
||||
Some(([lo, hi], _)) if lo <= 0.0 && 0.0 <= hi => Verdict::WithinNoise,
|
||||
Some(_) if diff.abs() < 0.05_f64.max(0.02 * a) => Verdict::Negligible,
|
||||
Some(([lo, _], _)) if lo > 0.0 => Verdict::Slower,
|
||||
Some(_) => Verdict::Faster,
|
||||
};
|
||||
Paired {
|
||||
pairs: n,
|
||||
a: round(a) + 0.0,
|
||||
b: round(b) + 0.0,
|
||||
diff: round(diff) + 0.0,
|
||||
diff_pct: (a != 0.0).then(|| round(100.0 * diff / a) + 0.0),
|
||||
ci95: interval.map(|([lo, hi], _)| [round(lo) + 0.0, round(hi) + 0.0]),
|
||||
confidence: interval.map(|(_, c)| round4(c)),
|
||||
noise: None,
|
||||
verdict,
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest symmetric interval of the sorted Walsh averages whose tails hold at most 2.5% each, and its confidence.
|
||||
fn interval(n: usize, walsh: &[f64]) -> Option<([f64; 2], f64)> {
|
||||
let m = walsh.len();
|
||||
// Null probabilities of T+ (halving is exact in binary, so these are the counts over 2^n).
|
||||
let mut p = vec![0.0f64; m + 1];
|
||||
p[0] = 1.0;
|
||||
for r in 1..=n {
|
||||
for t in (0..=m).rev() {
|
||||
p[t] = (p[t] + if t >= r { p[t - r] } else { 0.0 }) / 2.0;
|
||||
}
|
||||
}
|
||||
let (mut k, mut tail, mut best) = (0, 0.0, 0.0);
|
||||
while k < m {
|
||||
tail += p[k];
|
||||
if tail > 0.025 {
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
best = tail;
|
||||
}
|
||||
(k >= 1).then(|| ([walsh[k - 1], walsh[m - k]], 1.0 - 2.0 * best))
|
||||
}
|
||||
|
||||
/// The result of an A/B run: pairs of blocks in B A A B order, recomputable from the report's `frames`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ab {
|
||||
pub switch: String,
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
pub pairs: u32,
|
||||
pub block_s: f64,
|
||||
pub warmup_s: f64,
|
||||
/// Block 0 (measured before any switch) lies outside the later B blocks.
|
||||
pub first_block_outside_range: bool,
|
||||
/// By `gpu_p50_ms`, `cpu_p50_ms`, `frame_p50_ms`; a metric missing from any block is left out.
|
||||
pub result: BTreeMap<String, Paired>,
|
||||
}
|
||||
|
||||
impl Ab {
|
||||
/// Pairs the `ab.a`/`ab.b` entries of `frames` by `repeat` (0, 1, 2...): pair k is blocks 2k and 2k+1.
|
||||
pub fn from_frames(
|
||||
switch: &str,
|
||||
a: &str,
|
||||
b: &str,
|
||||
block_s: f64,
|
||||
warmup_s: f64,
|
||||
frames: &[Frames],
|
||||
) -> Result<Ab, String> {
|
||||
let mut blocks: Vec<&Frames> = frames
|
||||
.iter()
|
||||
.filter(|f| f.phase == "ab.a" || f.phase == "ab.b")
|
||||
.collect();
|
||||
blocks.sort_by_key(|f| f.repeat);
|
||||
if blocks.is_empty()
|
||||
|| !blocks.len().is_multiple_of(2)
|
||||
|| blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, f)| f.repeat as usize != i)
|
||||
{
|
||||
return Err("A/B blocks must be numbered 0..N with N even".to_owned());
|
||||
}
|
||||
if blocks.chunks(2).any(|p| p[0].phase == p[1].phase) {
|
||||
return Err("each pair needs one ab.a and one ab.b block".to_owned());
|
||||
}
|
||||
let pairs = blocks.len() / 2;
|
||||
let metrics: [(&str, Getter); 3] = [
|
||||
("gpu_p50_ms", |f| f.gpu_p50_ms),
|
||||
("cpu_p50_ms", |f| f.cpu_p50_ms),
|
||||
("frame_p50_ms", |f| Some(f.p50_ms)),
|
||||
];
|
||||
let mut result = BTreeMap::new();
|
||||
let mut series: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||
for (name, get) in metrics {
|
||||
let Some(values) = blocks.iter().map(|f| get(f)).collect::<Option<Vec<f64>>>() else {
|
||||
continue;
|
||||
};
|
||||
let by_arm: Vec<(f64, f64)> = blocks
|
||||
.chunks(2)
|
||||
.zip(values.chunks(2))
|
||||
.map(|(p, v)| {
|
||||
if p[0].phase == "ab.a" {
|
||||
(v[0], v[1])
|
||||
} else {
|
||||
(v[1], v[0])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut stat = paired(&by_arm);
|
||||
let gaps: Vec<f64> = (1..values.len().saturating_sub(1))
|
||||
.step_by(2)
|
||||
.filter(|&i| blocks[i].phase == blocks[i + 1].phase)
|
||||
.map(|i| (values[i] - values[i + 1]).abs())
|
||||
.collect();
|
||||
stat.noise = (!gaps.is_empty()).then(|| round(median(&gaps)) + 0.0);
|
||||
result.insert(name.to_owned(), stat);
|
||||
series.insert(name, values);
|
||||
}
|
||||
let first_block_outside_range = series
|
||||
.get("gpu_p50_ms")
|
||||
.or_else(|| series.get("frame_p50_ms"))
|
||||
.is_some_and(|values| {
|
||||
let later: Vec<f64> = (1..values.len())
|
||||
.filter(|&i| blocks[i].phase == "ab.b")
|
||||
.map(|i| values[i])
|
||||
.collect();
|
||||
blocks[0].phase == "ab.b"
|
||||
&& !later.is_empty()
|
||||
&& (values[0] < later.iter().copied().fold(f64::INFINITY, f64::min)
|
||||
|| values[0] > later.iter().copied().fold(f64::NEG_INFINITY, f64::max))
|
||||
});
|
||||
Ok(Ab {
|
||||
switch: switch.to_owned(),
|
||||
a: a.to_owned(),
|
||||
b: b.to_owned(),
|
||||
pairs: pairs as u32,
|
||||
block_s,
|
||||
warmup_s,
|
||||
first_block_outside_range,
|
||||
result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{App, Kind, Report};
|
||||
|
||||
// Expected values: exact-rational Walsh averages and a brute-force sign enumeration, run once outside this code.
|
||||
fn check(
|
||||
a: &[f64],
|
||||
b: &[f64],
|
||||
diff: f64,
|
||||
ci: Option<[f64; 2]>,
|
||||
conf: Option<f64>,
|
||||
verdict: Verdict,
|
||||
) {
|
||||
let pairs: Vec<(f64, f64)> = a.iter().copied().zip(b.iter().copied()).collect();
|
||||
let p = paired(&pairs);
|
||||
assert_eq!(
|
||||
(p.pairs, p.diff, p.ci95, p.confidence, p.verdict),
|
||||
(a.len(), diff, ci, conf, verdict),
|
||||
"{b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decided_vectors() {
|
||||
use Verdict::*;
|
||||
let z = |n: usize| vec![0.0; n];
|
||||
let up = |n: usize| (1..=n).map(|v| v as f64).collect::<Vec<_>>();
|
||||
check(&z(8), &up(8), 4.5, Some([2.0, 7.0]), Some(0.9609), Slower);
|
||||
let wobble = [-0.3, 0.2, -0.1, 0.4, -0.2, 0.1, 0.3, -0.4];
|
||||
check(
|
||||
&z(8),
|
||||
&wobble,
|
||||
0.0,
|
||||
Some([-0.3, 0.3]),
|
||||
Some(0.9609),
|
||||
WithinNoise,
|
||||
);
|
||||
check(&z(6), &up(6), 3.5, Some([1.0, 6.0]), Some(0.9688), Slower);
|
||||
check(&z(5), &up(5), 3.0, None, None, TooFewPairs);
|
||||
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
|
||||
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
|
||||
check(&a, &b, 1.96, Some([1.905, 2.03]), Some(0.9609), Slower);
|
||||
let tiny: Vec<f64> = (1..=8).map(|v| 10.0 + v as f64 / 100.0).collect();
|
||||
check(
|
||||
&[10.0; 8],
|
||||
&tiny,
|
||||
0.045,
|
||||
Some([0.02, 0.07]),
|
||||
Some(0.9609),
|
||||
Negligible,
|
||||
);
|
||||
let down: Vec<f64> = up(8).iter().map(|_| 0.0).collect();
|
||||
check(
|
||||
&up(8),
|
||||
&down,
|
||||
-4.5,
|
||||
Some([-7.0, -2.0]),
|
||||
Some(0.9609),
|
||||
Faster,
|
||||
);
|
||||
let mixed = [0.5, -0.2, 0.0, 0.3, 0.9, -0.1, 0.4];
|
||||
check(
|
||||
&z(7),
|
||||
&mixed,
|
||||
0.225,
|
||||
Some([-0.1, 0.65]),
|
||||
Some(0.9531),
|
||||
WithinNoise,
|
||||
);
|
||||
let ten = [0.3, -0.1, 0.8, 0.2, -0.5, 0.6, 0.1, 0.4, 0.7, 0.05];
|
||||
check(
|
||||
&z(10),
|
||||
&ten,
|
||||
0.25,
|
||||
Some([-0.025, 0.55]),
|
||||
Some(0.9512),
|
||||
WithinNoise,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medians_percent_and_empty_input() {
|
||||
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
|
||||
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
|
||||
let p = paired(&a.iter().copied().zip(b).collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
(p.a, p.b, p.diff_pct, p.noise),
|
||||
(3.67, 5.625, Some(53.406), None)
|
||||
);
|
||||
assert_eq!(
|
||||
paired(&[(0.0, 1.0); 8]).diff_pct,
|
||||
None,
|
||||
"no percent of zero"
|
||||
);
|
||||
assert_eq!(paired(&[]).verdict, Verdict::TooFewPairs);
|
||||
}
|
||||
|
||||
fn block(phase: &str, repeat: u32, frame: f64, cpu: Option<f64>, gpu: Option<f64>) -> Frames {
|
||||
Frames {
|
||||
phase: phase.into(),
|
||||
repeat,
|
||||
frames: 100,
|
||||
seconds: 4.0,
|
||||
avg_fps: 250.0,
|
||||
low_1pct_fps: 100.0,
|
||||
low_01pct_fps: 90.0,
|
||||
p50_ms: frame,
|
||||
p95_ms: frame + 1.0,
|
||||
p99_ms: frame + 2.0,
|
||||
p999_ms: frame + 3.0,
|
||||
max_ms: frame + 4.0,
|
||||
cpu_p50_ms: cpu,
|
||||
gpu_p50_ms: gpu,
|
||||
histogram: vec![0; crate::HISTOGRAM_EDGES_MS.len()],
|
||||
}
|
||||
}
|
||||
|
||||
// B A A B B A A B B A A B, as gpu milliseconds; frame time is gpu + 1.
|
||||
fn twelve(first_b: f64) -> Vec<Frames> {
|
||||
let gpu = [
|
||||
first_b, 3.0, 3.2, 5.0, 5.2, 3.1, 3.1, 5.1, 5.4, 3.4, 3.3, 5.3,
|
||||
];
|
||||
gpu.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &g)| {
|
||||
block(
|
||||
if matches!(i % 4, 0 | 3) {
|
||||
"ab.b"
|
||||
} else {
|
||||
"ab.a"
|
||||
},
|
||||
i as u32,
|
||||
g + 1.0,
|
||||
Some(g / 2.0),
|
||||
Some(g),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from(frames: &[Frames]) -> Result<Ab, String> {
|
||||
Ab::from_frames("gpu-culling", "off", "on", 4.0, 2.0, frames)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_and_the_first_block_flag() {
|
||||
let ab = from(&twelve(6.4)).unwrap();
|
||||
assert!(
|
||||
ab.first_block_outside_range,
|
||||
"6.4 is 1 ms above the other B blocks"
|
||||
);
|
||||
let gpu = &ab.result["gpu_p50_ms"];
|
||||
// Neighbours (1,2) (3,4) (5,6) (7,8) (9,10): 0.2 0.2 0 0.3 0.1.
|
||||
assert_eq!(
|
||||
(gpu.pairs, gpu.a, gpu.b, gpu.noise),
|
||||
(6, 3.15, 5.25, Some(0.2))
|
||||
);
|
||||
assert_eq!(
|
||||
(gpu.ci95, gpu.verdict),
|
||||
(Some([1.8, 3.4]), Verdict::Slower),
|
||||
"every d is positive: min and max Walsh averages"
|
||||
);
|
||||
assert_eq!(ab.result["cpu_p50_ms"].noise, Some(0.1));
|
||||
assert_eq!(
|
||||
(ab.pairs, ab.switch.as_str(), ab.a.as_str(), ab.b.as_str()),
|
||||
(6, "gpu-culling", "off", "on")
|
||||
);
|
||||
assert!(!from(&twelve(5.2)).unwrap().first_block_outside_range);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_metric_missing_from_one_block_is_left_out() {
|
||||
let mut frames = twelve(5.2);
|
||||
frames[7].gpu_p50_ms = None;
|
||||
let ab = from(&frames).unwrap();
|
||||
assert_eq!(
|
||||
ab.result.keys().collect::<Vec<_>>(),
|
||||
["cpu_p50_ms", "frame_p50_ms"]
|
||||
);
|
||||
let mut frames = twelve(6.4);
|
||||
frames[0].gpu_p50_ms = None;
|
||||
frames[0].p50_ms = 6.0;
|
||||
assert!(
|
||||
!from(&frames).unwrap().first_block_outside_range,
|
||||
"frame time 6.0 is inside 6.0..6.4 of the later B blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_that_do_not_pair_are_refused() {
|
||||
assert!(from(&[]).is_err());
|
||||
assert!(from(&twelve(5.2)[..11]).is_err(), "odd number of blocks");
|
||||
let mut frames = twelve(5.2);
|
||||
frames[1].phase = "ab.b".into();
|
||||
assert!(from(&frames).is_err(), "a pair of two B blocks");
|
||||
let mut frames = twelve(5.2);
|
||||
frames[3].repeat = 9;
|
||||
assert!(from(&frames).is_err(), "a gap in the numbering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_with_ab_round_trips_and_old_ones_still_parse() {
|
||||
let mut r = Report::new(Kind::Game, App::default());
|
||||
let old = r.to_json();
|
||||
assert!(!old.contains("\"ab\""));
|
||||
assert_eq!(Report::parse(&old).unwrap().ab, None);
|
||||
r.frames = twelve(5.2);
|
||||
r.ab = Some(from(&r.frames).unwrap());
|
||||
let json = r.to_json();
|
||||
assert_eq!(Report::parse(&json).unwrap(), r);
|
||||
assert!(
|
||||
json.contains("\"first_block_outside_range\": false")
|
||||
&& json.contains("\"verdict\": \"slower\"")
|
||||
);
|
||||
assert_eq!(r.for_upload().ab, r.ab);
|
||||
let mut few = paired(&[(1.0, 2.0); 3]);
|
||||
few.noise = None;
|
||||
assert!(serde_json::to_string(&few)
|
||||
.unwrap()
|
||||
.contains("\"ci95\":null,\"confidence\":null"));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_system::command_output as output;
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -62,7 +61,6 @@ pub struct Display {
|
||||
pub refresh_hz: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
@@ -70,12 +68,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: horizon_system::cpu(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -86,14 +84,13 @@ impl Hardware {
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_system::storage(game_dir).map(str::to_owned),
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// What `horizon_system::Machine` reads.
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
@@ -101,31 +98,95 @@ struct Probes {
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_system::Machine::detect();
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_system::power()
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -138,7 +199,22 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -147,19 +223,45 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
horizon_system::windows_version()
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -172,6 +274,9 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod compare;
|
||||
mod hardware;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use hardware::power_source;
|
||||
pub use hardware::{Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
@@ -240,9 +234,6 @@ pub struct Report {
|
||||
pub metrics: BTreeMap<String, Summary>,
|
||||
#[serde(default)]
|
||||
pub frames: Vec<Frames>,
|
||||
/// Present in A/B runs only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ab: Option<compare::Ab>,
|
||||
/// Local only: a name the person running it chose.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
@@ -253,7 +244,6 @@ pub struct Report {
|
||||
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
@@ -271,7 +261,6 @@ impl Report {
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
ab: None,
|
||||
label: None,
|
||||
local: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -61,8 +61,6 @@ pub enum ClickEvent {
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
/// A `/profiler` line the client's own buttons run; never decoded from a server's text, so only they reach the local profiler.
|
||||
Profiler(String),
|
||||
}
|
||||
|
||||
/// A non-character `minecraft:object` embedded in a text component.
|
||||
@@ -204,12 +202,7 @@ impl Text {
|
||||
}
|
||||
}
|
||||
pub fn styled(s: impl Into<String>, style: Style) -> Self {
|
||||
let mut text = Self::literal(s).with_style(&style);
|
||||
// `inherit` never passes an object on, but a styled span's own style carries it.
|
||||
for span in &mut text.spans {
|
||||
span.style.inline_object.clone_from(&style.inline_object);
|
||||
}
|
||||
text
|
||||
Self::literal(s).with_style(&style)
|
||||
}
|
||||
pub fn colored(s: impl Into<String>, color: u32) -> Self {
|
||||
Self::styled(s, Style::color(color))
|
||||
@@ -626,9 +619,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
ClickEvent::Profiler(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
}
|
||||
@@ -782,27 +772,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_styled_object_span_keeps_its_object_through_append_and_slice() {
|
||||
let object = InlineObject::Atlas {
|
||||
atlas: "minecraft:gui".into(),
|
||||
sprite: "horizon:gui/mark".into(),
|
||||
fallback: "H".into(),
|
||||
};
|
||||
let sprite = Text::styled(
|
||||
"\u{fffc}",
|
||||
Style {
|
||||
inline_object: Some(object.clone()),
|
||||
..Style::color(WHITE)
|
||||
},
|
||||
);
|
||||
let line = Text::colored("[", DARK_GRAY)
|
||||
.append(sprite)
|
||||
.append(Text::colored("] ", DARK_GRAY));
|
||||
let line = line.slice(0, 3);
|
||||
assert_eq!(line.spans[1].style.inline_object, Some(object));
|
||||
assert_eq!(line.plain(), "[H]");
|
||||
}
|
||||
#[test]
|
||||
fn appending_builds_the_same_tree_in_linear_time() {
|
||||
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
|
||||
// takes the siblings itself; a copy taken before an append keeps its own tree.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -27,11 +27,6 @@ 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" }
|
||||
# The .hzperf capture format and writer (docs/perf-platform.md).
|
||||
horizon-perf-format = { path = "../horizon-perf-format" }
|
||||
# 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,8 +34,8 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
|
||||
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).
|
||||
@@ -132,10 +127,10 @@ workspace = true
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Reads back the captures the server_perf tests write.
|
||||
horizon-perf = { path = "../horizon-perf" }
|
||||
# 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"] }
|
||||
# lan_bot turns the dedicated server's anticheat on, as the game installs it (`lan::watch`).
|
||||
horizon-anticheat = { path = "../horizon-anticheat", features = ["hub"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
|
||||
@@ -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" }
|
||||
@@ -23,7 +24,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-perf-format = { path = "../horizon-perf-format" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
@@ -75,9 +75,6 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# The `scene_replay` example's allocator counts allocations (horizon-threads' `alloc-count`).
|
||||
alloc-count = ["horizon-threads/alloc-count"]
|
||||
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
@@ -87,7 +84,6 @@ horizon-extras = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-perf = { path = "../horizon-perf" }
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
|
||||
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
|
||||
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
|
||||
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
|
||||
//! [--world SAVE --leaf-culling MODE] [--memory on]`
|
||||
//! `--screenshot` saves the first view's last frame, after the timed frames.
|
||||
//! `--world` meshes the captured sections again from that save's region files
|
||||
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
|
||||
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
|
||||
//! change can be measured on the captured view. A section whose 3x3 columns are
|
||||
//! not all saved keeps its captured mesh.
|
||||
//! `--gpu-detail on` adds GPU culling's dispatch times and, on Vulkan, the world passes' counters.
|
||||
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
|
||||
//! physical footprint by category (`footprint`), taken after the timed frames.
|
||||
use horizon_client_render::{
|
||||
@@ -25,7 +24,6 @@ use horizon_client_render::{
|
||||
gfx::{
|
||||
PreparedTextures, Renderer, ScreenshotRequest,
|
||||
capture::{self, AtlasCheck, Scene},
|
||||
pack_timing,
|
||||
panorama::Faces,
|
||||
},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -63,10 +61,7 @@ use winit::{
|
||||
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
|
||||
// gigabytes of the meshes `--world` frees dirty.
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: horizon_threads::GameAllocator = horizon_threads::GAME_ALLOCATOR;
|
||||
|
||||
/// Frames drawn with detail on, enough for a few timed frames of each kind.
|
||||
const DETAIL_FRAMES: usize = 60;
|
||||
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Options {
|
||||
@@ -89,7 +84,6 @@ struct Options {
|
||||
world: Option<PathBuf>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
memory: bool,
|
||||
gpu_detail: bool,
|
||||
}
|
||||
|
||||
fn on_off(value: &str) -> Result<bool, String> {
|
||||
@@ -142,7 +136,6 @@ fn options() -> Result<Options, String> {
|
||||
"--screenshot" => o.screenshot = Some(value.into()),
|
||||
"--world" => o.world = Some(value.into()),
|
||||
"--memory" => o.memory = on_off(&value)?,
|
||||
"--gpu-detail" => o.gpu_detail = on_off(&value)?,
|
||||
"--leaf-culling" => {
|
||||
o.leaf_culling = LeafCullingMode::ALL
|
||||
.into_iter()
|
||||
@@ -407,23 +400,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None => notes.push("screenshot: not saved".into()),
|
||||
}
|
||||
}
|
||||
let mut detail = serde_json::Map::new();
|
||||
if o.gpu_detail {
|
||||
renderer.set_pass_detail(true);
|
||||
for _ in 0..DETAIL_FRAMES {
|
||||
draw(&mut renderer)?;
|
||||
}
|
||||
detail.insert(
|
||||
"gpu_pass_parts_median_ms".into(),
|
||||
pack_timing::parts_json(&renderer.own_pass_parts()),
|
||||
);
|
||||
let unit = renderer.pass_detail_support().and_then(|s| s.fragment_unit);
|
||||
detail.insert(
|
||||
"gpu_counters_median".into(),
|
||||
pack_timing::counters_by_pass_json(&renderer.own_pass_counters(), unit),
|
||||
);
|
||||
renderer.set_pass_detail(false);
|
||||
}
|
||||
views.push(json!({
|
||||
"yaw_offset": offset,
|
||||
"gpu_ms": summary(&gpu),
|
||||
@@ -441,9 +417,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// indices hidden.
|
||||
"culled": renderer.culled_sections(),
|
||||
}));
|
||||
if let Some(Value::Object(view)) = views.last_mut() {
|
||||
view.extend(detail);
|
||||
}
|
||||
}
|
||||
let section_draws = if renderer.flat_draws() {
|
||||
"flat"
|
||||
@@ -451,11 +424,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"indirect"
|
||||
};
|
||||
let memory = o.memory.then(|| memory_report(&renderer));
|
||||
let gpu_detail = o
|
||||
.gpu_detail
|
||||
.then(|| renderer.pass_detail_support())
|
||||
.flatten()
|
||||
.map(pack_timing::support_json);
|
||||
let report = json!({
|
||||
"capture": o.dir.display().to_string(),
|
||||
"adapter": renderer.info.name,
|
||||
@@ -486,7 +454,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"notes": notes,
|
||||
"views": views,
|
||||
"memory": memory,
|
||||
"gpu_detail": gpu_detail,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -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);
|
||||
@@ -292,17 +291,12 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
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;
|
||||
@@ -451,9 +445,9 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
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_gains), color.a);
|
||||
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,
|
||||
@@ -472,10 +466,9 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
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_gains), color.a);
|
||||
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() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,164 +0,0 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -407,7 +405,7 @@ impl CustomSky {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +426,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -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 {
|
||||
@@ -702,7 +682,7 @@ mod tests {
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ struct Layer {
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,27 +54,18 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(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_gains);
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +75,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -71,7 +71,7 @@ use super::{
|
||||
Frustum, SectionKey,
|
||||
arena::{Arena, try_create},
|
||||
occlusion::Reach,
|
||||
pack_timing::{PassTiming, compute_parts},
|
||||
pack_timing::PassTiming,
|
||||
resident,
|
||||
submit::Queue,
|
||||
upload::Uploads,
|
||||
@@ -374,21 +374,6 @@ pub(super) enum Prepared {
|
||||
OutOfMemory,
|
||||
}
|
||||
|
||||
/// A part of a compute pass: `pipeline` over `[x, y]` workgroups, nothing when `x` is 0.
|
||||
fn step<'a>(
|
||||
group: &'a wgpu::BindGroup,
|
||||
pipeline: &'a wgpu::ComputePipeline,
|
||||
[x, y]: [u32; 2],
|
||||
) -> impl FnMut(&mut wgpu::ComputePass<'_>) + 'a {
|
||||
move |pass| {
|
||||
if x > 0 {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.dispatch_workgroups(x, y, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's `tables` before the regions, into `words`, and the parameters
|
||||
/// the frustum test and facing read with them; `None` when the CPU draws the
|
||||
/// frame (`Prepared::Cpu`): a sum of a plane's terms could leave i32
|
||||
@@ -1205,13 +1190,12 @@ impl GpuCulling {
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
input: &FrameInputs<'_>,
|
||||
timing: Option<&mut PassTiming>,
|
||||
) -> Prepared {
|
||||
self.pages.clear();
|
||||
self.ids = 0;
|
||||
self.n_updates = 0;
|
||||
self.occluding = false;
|
||||
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
|
||||
self.prepare_buffers(device, queue, uploads, encoder, input)
|
||||
.unwrap_or(Prepared::OutOfMemory)
|
||||
}
|
||||
|
||||
@@ -1222,7 +1206,6 @@ impl GpuCulling {
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
input: &FrameInputs<'_>,
|
||||
mut timing: Option<&mut PassTiming>,
|
||||
) -> Option<Prepared> {
|
||||
// Every buffer grows before anything is written. One made anew drops
|
||||
// the bind group at once, and a new section table needs every record:
|
||||
@@ -1304,9 +1287,6 @@ impl GpuCulling {
|
||||
occl = OCCLUDE | if probe { PROBE } else { 0 };
|
||||
}
|
||||
}
|
||||
let uploading = timing
|
||||
.as_mut()
|
||||
.and_then(|t| t.encoder_begin(encoder, "GPU culling uploads"));
|
||||
if self.records.all_dirty {
|
||||
// The whole table, after it grew or more records changed than
|
||||
// updates take (GPU culling switched on in a loaded world, a
|
||||
@@ -1372,9 +1352,6 @@ impl GpuCulling {
|
||||
// are written before they are read.
|
||||
let cleared = if self.occluding { tests_at } else { phase1_end };
|
||||
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
|
||||
if let Some(t) = timing {
|
||||
t.encoder_end(encoder, uploading);
|
||||
}
|
||||
if self.group.is_none() {
|
||||
self.group = Some(self.bind(device));
|
||||
if let Some(test) = &mut self.test {
|
||||
@@ -1426,24 +1403,23 @@ impl GpuCulling {
|
||||
let Some(group) = self.group.as_ref().filter(|_| self.ids > 0) else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("GPU culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling")),
|
||||
});
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
let [apply, cull, scan, scatter] = &self.pipelines;
|
||||
if self.n_updates > 0 {
|
||||
pass.set_pipeline(apply);
|
||||
pass.dispatch_workgroups(self.n_updates.div_ceil(WORKGROUP), 1, 1);
|
||||
}
|
||||
let blocks = self.ids.div_ceil(WORKGROUP);
|
||||
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
|
||||
let updates = [self.n_updates.div_ceil(WORKGROUP), 1];
|
||||
let (mut apply, mut cull) = (step(group, apply, updates), step(group, cull, rows));
|
||||
let pages = [self.pass_pages, 1];
|
||||
let (mut scan, mut scatter) = (step(group, scan, pages), step(group, scatter, rows));
|
||||
compute_parts(
|
||||
encoder,
|
||||
timing,
|
||||
"GPU culling",
|
||||
&mut [
|
||||
("apply", &mut apply),
|
||||
("cull", &mut cull),
|
||||
("scan", &mut scan),
|
||||
("scatter", &mut scatter),
|
||||
],
|
||||
);
|
||||
pass.set_pipeline(cull);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
pass.set_pipeline(scan);
|
||||
pass.dispatch_workgroups(self.pass_pages, 1, 1);
|
||||
pass.set_pipeline(scatter);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
}
|
||||
|
||||
/// The kept sections' solid draws, a count draw per page, with the quad
|
||||
@@ -1518,22 +1494,19 @@ impl GpuCulling {
|
||||
if !self.occluding {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("GPU culling phase 2"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
|
||||
});
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
let [count2, scan2, scatter2] = &test.phase2;
|
||||
let blocks = self.ids.div_ceil(WORKGROUP);
|
||||
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
|
||||
let pages = [self.pass_pages, 1];
|
||||
let (mut count, mut scatter) = (step(group, count2, rows), step(group, scatter2, rows));
|
||||
let mut scan = step(group, scan2, pages);
|
||||
compute_parts(
|
||||
encoder,
|
||||
timing,
|
||||
"GPU culling phase 2",
|
||||
&mut [
|
||||
("count2", &mut count),
|
||||
("scan2", &mut scan),
|
||||
("scatter2", &mut scatter),
|
||||
],
|
||||
);
|
||||
pass.set_pipeline(count2);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
pass.set_pipeline(scan2);
|
||||
pass.dispatch_workgroups(self.pass_pages, 1, 1);
|
||||
pass.set_pipeline(scatter2);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
}
|
||||
|
||||
/// After the frame's world passes (phase 2's counts too), in the same
|
||||
@@ -2747,11 +2720,6 @@ mod tests {
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let needed =
|
||||
wgpu::Features::MULTI_DRAW_INDIRECT_COUNT | wgpu::Features::INDIRECT_FIRST_INSTANCE;
|
||||
let timing = adapter.features()
|
||||
& (wgpu::Features::TIMESTAMP_QUERY
|
||||
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
|
||||
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
|
||||
| wgpu::Features::SHADER_EARLY_DEPTH_TEST);
|
||||
if !adapter.features().contains(needed)
|
||||
|| super::super::flat::is_broken_intel_vulkan(&adapter.get_info())
|
||||
{
|
||||
@@ -2759,7 +2727,7 @@ mod tests {
|
||||
}
|
||||
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: needed | timing,
|
||||
required_features: needed,
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
@@ -2866,11 +2834,6 @@ mod tests {
|
||||
// was drawn.
|
||||
let (mut eyed, mut hidden, mut several) = (0, 0, 0);
|
||||
let (mut edge, mut edge_drawn) = (None, false);
|
||||
// Timed in detail as the renderer does: the commands, and so the draws, stay the same.
|
||||
let mut timing = PassTiming::new(device, queue);
|
||||
if let Some(t) = &mut timing {
|
||||
t.set_detail(true);
|
||||
}
|
||||
for case in 0..25 {
|
||||
let radius = [2, 5, 9][case % 3];
|
||||
let at_edge = case == 24;
|
||||
@@ -3018,9 +2981,6 @@ mod tests {
|
||||
edge = Some(id);
|
||||
}
|
||||
let whole = gpu.records.all_dirty;
|
||||
if let Some(t) = &mut timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
camera,
|
||||
@@ -3040,18 +3000,11 @@ mod tests {
|
||||
camera: camera + DVec3::Y * 2.0e7,
|
||||
..input
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
|
||||
assert_eq!(prepared, Prepared::Cpu, "case 7");
|
||||
assert!(gpu.records.all_dirty || !gpu.records.dirty.is_empty());
|
||||
}
|
||||
let prepared = gpu.prepare(
|
||||
device,
|
||||
queue,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
timing.as_mut(),
|
||||
);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
|
||||
assert_eq!(prepared, Prepared::Gpu, "case {case}");
|
||||
match case {
|
||||
4 => assert!(
|
||||
@@ -3107,15 +3060,12 @@ mod tests {
|
||||
culled += 1;
|
||||
}
|
||||
}
|
||||
gpu.dispatch(&mut encoder, timing.as_mut());
|
||||
gpu.dispatch(&mut encoder, None);
|
||||
// Its statistics come back with a CPU part of the case's own.
|
||||
let cpu = [case * 7, case * 3, case];
|
||||
gpu.copy_stats(&mut encoder, case as u64, cpu);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(t) = &mut timing {
|
||||
t.submitted(queue);
|
||||
}
|
||||
gpu.submitted().expect("a readback free")();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
|
||||
@@ -3174,18 +3124,6 @@ mod tests {
|
||||
"of {eyed} from an eye, {hidden} with a bucket left out, {several} in three runs"
|
||||
);
|
||||
assert!(edge_drawn, "the bucket at the section's edge");
|
||||
if let Some(t) = &timing {
|
||||
let parts = t.part_medians();
|
||||
let names = |pass: &str| {
|
||||
let parts = parts.iter().find(|p| p.0 == pass)?;
|
||||
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
|
||||
};
|
||||
assert_eq!(
|
||||
names("GPU culling"),
|
||||
Some(vec!["apply", "cull", "scan", "scatter"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
}
|
||||
assert!(culled > 2_000, "{culled} within the distance left out");
|
||||
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
|
||||
assert!(
|
||||
@@ -3198,113 +3136,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Detail times both phases and the uploads and leaves counts and draws as an untimed frame's.
|
||||
#[test]
|
||||
fn detail_times_both_phases_and_leaves_the_draws_alone() {
|
||||
on_each_device(|device, queue| {
|
||||
let mut rng = Rng(0x1F83_D9AB_FB41_BD6B);
|
||||
let (frustum, _) = view(&mut rng);
|
||||
let records: Vec<Record> = (0..400)
|
||||
.map(|i| {
|
||||
let key = (rng.range(-6, 6), rng.range(0, 23), rng.range(-6, 6));
|
||||
Record::new(
|
||||
key,
|
||||
i % 3,
|
||||
u64::from(i) * 1024,
|
||||
1 + rng.below(500),
|
||||
UNBOUNDED,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut timing = PassTiming::new(device, queue);
|
||||
let early = device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST);
|
||||
let Some(t) = timing.as_mut().filter(|_| early) else {
|
||||
eprintln!("no timestamps or early depth test: skipped");
|
||||
return;
|
||||
};
|
||||
t.set_detail(true);
|
||||
let run = |timing: &mut Option<PassTiming>| {
|
||||
let mut gpu = GpuCulling::new(device, Some(wgpu::TextureFormat::Rgba8Unorm))
|
||||
.expect("the pass");
|
||||
let mut uploads = Uploads::new(device);
|
||||
for r in &records {
|
||||
gpu.records.insert(*r);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for frame in 0..12 {
|
||||
if let Some(t) = timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
camera: DVec3::new(0.5, 70.0, 0.5),
|
||||
min_y: -64,
|
||||
sections: 24,
|
||||
radius: 5,
|
||||
frustum: &frustum,
|
||||
reach: None,
|
||||
eye: None,
|
||||
view_proj: Mat4::IDENTITY,
|
||||
occlude: true,
|
||||
};
|
||||
let prepared = gpu.prepare(
|
||||
device,
|
||||
queue,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
timing.as_mut(),
|
||||
);
|
||||
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
|
||||
assert!(gpu.occluding(), "frame {frame}: phase 2 runs");
|
||||
gpu.dispatch(&mut encoder, timing.as_mut());
|
||||
gpu.compact(&mut encoder, timing.as_mut());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(t) = timing {
|
||||
t.submitted(queue);
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
out.push((
|
||||
read(device, queue, &gpu.counts.buffer, (STATS + 2 * pages) * 4),
|
||||
read(device, queue, &gpu.draws.buffer, gpu.draws.bytes),
|
||||
));
|
||||
}
|
||||
out
|
||||
};
|
||||
let timed = run(&mut timing);
|
||||
let plain = run(&mut None);
|
||||
assert_eq!(timed, plain, "detail changes what is drawn");
|
||||
let t = timing.as_ref().expect("the timer");
|
||||
let parts = t.part_medians();
|
||||
let names = |pass: &str| {
|
||||
let parts = parts.iter().find(|p| p.0 == pass)?;
|
||||
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
|
||||
};
|
||||
assert_eq!(
|
||||
names("GPU culling"),
|
||||
Some(vec!["apply", "cull", "scan", "scatter"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
names("GPU culling phase 2"),
|
||||
Some(vec!["count2", "scan2", "scatter2"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
// The uploads are a pass of their own, never a part of one.
|
||||
assert_eq!(names("GPU culling uploads"), None, "{parts:?}");
|
||||
let passes = t.medians();
|
||||
assert_eq!(
|
||||
passes.iter().any(|p| p.0 == "GPU culling uploads"),
|
||||
t.detail_support().encoder_stamps,
|
||||
"{passes:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The statistics come back with their frame, in place and from the submit
|
||||
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
|
||||
/// with its `after` handed back): three frames on their way fill the ring, a
|
||||
@@ -3546,7 +3377,7 @@ struct Quad {
|
||||
view_proj: Mat4::IDENTITY,
|
||||
occlude: false,
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
|
||||
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
//!
|
||||
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
|
||||
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
|
||||
//! peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -103,53 +99,38 @@ struct Layered {
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
|
||||
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
|
||||
/// `full` (all of it), the texel's own brightness before lighting.
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub gain: f32,
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
|
||||
/// glowstone and campfires, the sun.
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
gain: 1.6,
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
gain: 2.4,
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
gain: 2.8,
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
gain: 4.0,
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// The scene's peak, in multiples of paper white: just above the brightest light the world
|
||||
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
|
||||
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
|
||||
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
|
||||
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
|
||||
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
|
||||
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
|
||||
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
|
||||
pub const SUNLIT_GAIN: f32 = 3.0;
|
||||
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
|
||||
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
|
||||
/// changes once, when the float frame replaces it.
|
||||
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
|
||||
|
||||
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
|
||||
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
|
||||
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
|
||||
@@ -350,7 +331,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -963,19 +944,16 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
|
||||
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
|
||||
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
|
||||
let p = self.params(world, false);
|
||||
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
|
||||
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
|
||||
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 20];
|
||||
let mut params = [0.0; 16];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -990,17 +968,12 @@ impl Hdr {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
params[8..12].copy_from_slice(&COMPAT_SHARES);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
|
||||
// Lighting off, a pack's own light, the title) the curve's source follows the display.
|
||||
params[16] = if world && self.lighting {
|
||||
SCENE_PEAK
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
params
|
||||
}
|
||||
|
||||
@@ -1077,9 +1050,8 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
|
||||
/// surface by, 4, which looked right across biomes; less only where there is little headroom
|
||||
/// to ease into. The float frame's is `SUNLIT_GAIN`.
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
@@ -1097,8 +1069,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
|
||||
// writers take the gain that a share gives.
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@@ -1117,20 +1088,19 @@ var<private> params: ReferenceParams;
|
||||
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
|
||||
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
|
||||
let expected = reference_lit(color, mark, shows, eased);
|
||||
let gains = 1.0 + (params.peak - 1.0) * params.share;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
let quantized = round(saturate(stored) * 255.0) / 255.0;
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
@@ -1349,12 +1319,10 @@ var<private> params: ReferenceParams;
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
|
||||
// of the headroom up to that peak (`writers_lift`, `display_lift`).
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
lift: [[f32; 4]; 2],
|
||||
hdr: &Hdr,
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
@@ -1377,7 +1345,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
@@ -1405,9 +1373,10 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&lift),
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
@@ -1435,19 +1404,6 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
);
|
||||
}
|
||||
|
||||
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
|
||||
/// of the headroom up to the scene's peak.
|
||||
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
|
||||
}
|
||||
|
||||
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
|
||||
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let p = hdr.params(true, false);
|
||||
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
@@ -1460,9 +1416,8 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD:
|
||||
// the frozen oracle turns the marks into the light that display gives them, and the
|
||||
// layered display, whose curve is the identity at 1000 nits, must show it the same.
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
@@ -1486,14 +1441,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
// The light the compatibility frame shows: its own lift, not the writers'.
|
||||
legacy_light(
|
||||
&device,
|
||||
&mut encoder,
|
||||
display_lift(&legacy),
|
||||
&marked,
|
||||
&frame,
|
||||
);
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
@@ -1532,206 +1480,78 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
/// The world's writers read nothing of the display: the same gains and the same scene peak
|
||||
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
#[test]
|
||||
fn the_writers_light_is_the_same_on_every_display() {
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let written = hdr.draw_params(true);
|
||||
assert_eq!(
|
||||
written,
|
||||
(
|
||||
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
|
||||
[1.6, 2.4, 2.8, 4.0]
|
||||
)
|
||||
);
|
||||
for (paper, peak) in [
|
||||
(203.0, 300.0),
|
||||
(203.0, 600.0),
|
||||
(203.0, 1500.0),
|
||||
(400.0, 600.0),
|
||||
(480.0, 200.0),
|
||||
(80.0, 10000.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
|
||||
assert_eq!(
|
||||
hdr.params(true, false)[16],
|
||||
super::SCENE_PEAK,
|
||||
"{paper}/{peak}"
|
||||
);
|
||||
}
|
||||
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
|
||||
// nothing says where their light ends, so the curve's source follows the display.
|
||||
assert_eq!(hdr.params(false, false)[16], 0.0);
|
||||
hdr.lighting = false;
|
||||
assert_eq!(hdr.draw_params(true).0[1], 1.0);
|
||||
assert_eq!(hdr.params(true, false)[16], 0.0);
|
||||
}
|
||||
|
||||
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
|
||||
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
|
||||
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
|
||||
#[test]
|
||||
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let scene_peak = super::SCENE_PEAK;
|
||||
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
|
||||
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
if curve {
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
|
||||
// light just above S that the curve holds at S, and whether that comes back to the same
|
||||
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
|
||||
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
|
||||
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
|
||||
(0..4).all(|c| {
|
||||
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
|
||||
})
|
||||
};
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let headroom = 1000.0 / 203.0;
|
||||
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
|
||||
let after =
|
||||
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
passes(hdr, &mut encoder);
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
|
||||
// Linear light in and out, in units of paper white.
|
||||
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
|
||||
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
|
||||
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
|
||||
assert_eq!(a, 1.0);
|
||||
[r, g, b].map(|c| c.powf(2.2))
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
|
||||
// A display at or above the mastering one (T >= S): every stored value up to S comes
|
||||
// out exactly as it went in, grey or coloured, and light above S is held at S with its
|
||||
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
|
||||
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
|
||||
for (paper, peak) in [
|
||||
(203.0, 1000.0),
|
||||
(203.0, 1500.0),
|
||||
(203.0, 10000.0),
|
||||
(480.0, 10000.0),
|
||||
(10.0, 200.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for i in 0..=64 {
|
||||
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
|
||||
let exact = i < 64;
|
||||
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
|
||||
"{peak}: {i}"
|
||||
);
|
||||
}
|
||||
for light in [8.0f32, 50.0] {
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
|
||||
"{peak}: {light}: {r} {g} {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
|
||||
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
|
||||
// where the gates' curve, whose source followed the display, kept it at 203; the
|
||||
// highlights above it keep more of their steps.
|
||||
for (paper, peak, knee, checkpoints) in [
|
||||
(
|
||||
203.0,
|
||||
600.0,
|
||||
2.0,
|
||||
&[
|
||||
(2.5, 2.47516),
|
||||
(3.0, 2.75699),
|
||||
(4.0, 2.9404),
|
||||
(scene_peak, 600.0 / 203.0),
|
||||
(8.0, 600.0 / 203.0),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
203.0,
|
||||
300.0,
|
||||
0.75,
|
||||
&[
|
||||
(0.9, 0.89289),
|
||||
(1.0, 0.97365),
|
||||
(1.2, 1.10406),
|
||||
(1.4, 1.20134),
|
||||
(2.0, 1.36833),
|
||||
(4.0, 1.47641),
|
||||
(8.0, 300.0 / 203.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
400.0,
|
||||
600.0,
|
||||
0.8,
|
||||
&[
|
||||
(1.0, 0.97973),
|
||||
(1.2, 1.11501),
|
||||
(1.5, 1.25607),
|
||||
(2.0, 1.38818),
|
||||
(scene_peak, 1.5),
|
||||
],
|
||||
),
|
||||
// No headroom (a peak under paper white): the scene is compressed into white.
|
||||
(
|
||||
203.0,
|
||||
150.0,
|
||||
0.4,
|
||||
&[
|
||||
(1.0, 0.78346),
|
||||
(1.5, 0.90244),
|
||||
(2.0, 0.95589),
|
||||
(4.0, 0.99944),
|
||||
(scene_peak, 1.0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for light in [0.0f32, 0.3, knee] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
|
||||
"{paper}/{peak}: {light}"
|
||||
);
|
||||
}
|
||||
for &(light, want) in checkpoints {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others; a
|
||||
// half float's step in gamma is 0.1% of the light near white.
|
||||
let headroom = (peak / paper).max(1.0);
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 0.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
r >= last * 0.998 && r <= headroom * 1.002,
|
||||
"{peak}: {light}: {r}"
|
||||
);
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
|
||||
// the curve's source follows the display, the knee at 60% of its peak and at least
|
||||
// white, as before one peak. At 300 nits the source's peak is 645 nits.
|
||||
hdr.lighting = false;
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.0f32, 0.5, 0.9, 1.0] {
|
||||
// Up to the knee (60% of the peak) the light stays exactly as stored.
|
||||
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
|
||||
// nits (13.7 times paper white), worked out apart from the shader.
|
||||
for (light, want) in [
|
||||
(3.5, 3.43791),
|
||||
(4.0, 3.78351),
|
||||
(headroom, 4.23556),
|
||||
(8.0, 4.82043),
|
||||
(12.0, 4.92462),
|
||||
(50.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others: the
|
||||
// light keeps its hue.
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 2.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
|
||||
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
|
||||
hdr.peak = 300.0;
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.5f32, 0.9, 1.0] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
for (light, want) in [
|
||||
(1.2, 1.16839),
|
||||
@@ -1740,12 +1560,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(2.0, 1.44024),
|
||||
(4.0, headroom),
|
||||
] {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// With no headroom there, a clip at white, channel by channel.
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
@@ -1762,7 +1582,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].gain = 3.0;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, legacy_light, texel, writers_lift},
|
||||
tests::{gpu, legacy_light, texel},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -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,
|
||||
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
|
||||
}
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
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_gains), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&gains);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
|
||||
@@ -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),
|
||||
@@ -2770,8 +2752,6 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
// As the renderer has it under a pack that draws: Horizon lights none of it.
|
||||
display.lighting = false;
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -696,16 +696,13 @@ impl Oit {
|
||||
),
|
||||
] {
|
||||
{
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
});
|
||||
let pixels = super::pixel_count(terrain_depth);
|
||||
let mut pass =
|
||||
super::pack_timing::counted(timing.as_deref_mut(), pass, pixels, label);
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, stage);
|
||||
}
|
||||
@@ -975,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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
//! The world pass after culling, and the renderer's GPU pass timing switches and readings.
|
||||
use super::{Renderer, native_pack, pack_post, pack_timing};
|
||||
|
||||
pub(super) fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
mut timing: Option<&mut pack_timing::PassTiming>,
|
||||
name: &str,
|
||||
pixels: u64,
|
||||
) -> pack_timing::CountedPass<'e> {
|
||||
let pass = after_culling_pass(
|
||||
encoder,
|
||||
view,
|
||||
depth,
|
||||
keep_depth,
|
||||
timing.as_deref_mut().and_then(|t| t.writes(name)),
|
||||
);
|
||||
pack_timing::counted(timing, pass, pixels, "before translucent")
|
||||
}
|
||||
/// The pixels of a depth attachment, for `fragments_per_pixel`.
|
||||
pub(super) fn pixel_count(depth: &wgpu::TextureView) -> u64 {
|
||||
let texture = depth.texture();
|
||||
u64::from(texture.width()) * u64::from(texture.height())
|
||||
}
|
||||
pub(super) fn after_culling_pass<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
timestamp_writes,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
// A shader pack reads it as depthtex0.
|
||||
store: if keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open or a capture records them).
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on;
|
||||
}
|
||||
/// Times the parts of Horizon's own passes while `on`.
|
||||
pub fn set_pass_detail(&mut self, on: bool) {
|
||||
self.pass_detail = on;
|
||||
}
|
||||
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
|
||||
/// samples than a measurement).
|
||||
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
|
||||
self.pass_timing_gap = gap;
|
||||
}
|
||||
/// Keeps each timed frame's pass times for `take_timed_frames` while `on` (a capture records them).
|
||||
pub fn set_keep_timed_frames(&mut self, on: bool) {
|
||||
self.keep_timed_frames = on;
|
||||
}
|
||||
/// The frames both timers timed since the last call, without waiting on the GPU.
|
||||
pub fn take_timed_frames(&mut self) -> pack_timing::TimedFrames {
|
||||
let own = self
|
||||
.own_timing
|
||||
.as_mut()
|
||||
.map(pack_timing::PassTiming::take_frames)
|
||||
.unwrap_or_default();
|
||||
let pack = match Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
Some(Some(t)) => t.take_frames(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
pack_timing::TimedFrames { own, pack }
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
pub(super) fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
native: &'a mut Option<native_pack::NativePack>,
|
||||
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
|
||||
match (pack, native) {
|
||||
(Some((_, Some(post))), _) => Some(&mut post.timing),
|
||||
(_, Some(native)) => Some(&mut native.timing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
|
||||
/// they are timed (`set_pass_timing`).
|
||||
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
|
||||
let timer = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
|
||||
/// timed (`set_pass_timing`).
|
||||
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each of Horizon's own passes with its parts' median GPU times in milliseconds.
|
||||
pub fn own_pass_parts(&self) -> Vec<(String, Vec<(String, f32)>)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::part_medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each counted pass's counters (medians), while detail is on and the device has them.
|
||||
pub fn own_pass_counters(&self) -> Vec<(String, pack_timing::PassCounters)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::counter_medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Both timers' readings and this adapter, for a capture; `None` when neither has timed a pass.
|
||||
pub fn gpu_readout(&self) -> Option<pack_timing::GpuReadout> {
|
||||
let own = self
|
||||
.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::readings)
|
||||
.filter(|r| !r.is_empty());
|
||||
let pack = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
.map(pack_timing::PassTiming::readings)
|
||||
.filter(|r| !r.is_empty());
|
||||
(own.is_some() || pack.is_some()).then(|| pack_timing::GpuReadout {
|
||||
adapter: self.info.clone(),
|
||||
own,
|
||||
pack,
|
||||
})
|
||||
}
|
||||
/// Whether dispatch parts and counters are being asked for (`set_pass_detail`).
|
||||
pub fn pass_detail(&self) -> bool {
|
||||
self.pass_detail
|
||||
}
|
||||
/// What detail timing can show on this device; `None` without pass timing.
|
||||
pub fn pass_detail_support(&self) -> Option<pack_timing::DetailSupport> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::detail_support)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -47,7 +47,7 @@ struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -184,7 +183,7 @@ impl Stars {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
|
||||
@@ -5,7 +5,7 @@ struct Stars {
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
@@ -20,8 +20,8 @@ struct Stars {
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
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_gains), b);
|
||||
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);
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
//! an adapter that can't present to the window gives way to another of its backend
|
||||
//! (#13493).
|
||||
use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, startup_step,
|
||||
surface_format, timing,
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// 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;
|
||||
|
||||
@@ -135,7 +135,6 @@ pub(super) async fn open_adapter(
|
||||
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
|
||||
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| pack_timing::PassTiming::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
|
||||
@@ -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"),
|
||||
@@ -239,17 +244,17 @@ impl Visbuffer {
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
clear: bool,
|
||||
mut timing: Option<&mut PassTiming>,
|
||||
) -> super::pack_timing::CountedPass<'e> {
|
||||
timing: Option<&mut PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
|
||||
let name = if clear {
|
||||
"Visibility IDs"
|
||||
} else {
|
||||
"Visibility IDs after culling"
|
||||
};
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon visibility buffer"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(name)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: ids,
|
||||
resolve_target: None,
|
||||
@@ -272,8 +277,7 @@ impl Visbuffer {
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
super::pack_timing::counted(timing, pass, super::pixel_count(depth), name)
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -360,13 +360,13 @@ impl Post {
|
||||
&mut self,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
gains: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, gains, windows)
|
||||
(true, settings, shares, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
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_gains), finished.a);
|
||||
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,
|
||||
|
||||
Loaded 100 of 996 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user