Merge origin/main into feat/admin-player-position

Resolve the generated docs/api/server-reference.md row: main's text with
the new dimension field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-04 14:55:35 +01:00
commit c2aa51c15f
611 files changed
+133355 -3559

No files matched your search

+2 -2
View File
@@ -9,9 +9,9 @@ permissions:
actions: write
jobs:
cancel:
# One of playhorizon's small runners, out of GitHub's shared pool: no checkout, no code of the pull request
# One of our small runners (horizon-light), out of GitHub's pool: no checkout, no code of the pull request
# (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
steps:
- name: Cancel the pull request's unfinished runs
env:
+107
View File
@@ -0,0 +1,107 @@
name: cargo-horizon-mod
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120): binstall reads
# [package.metadata.binstall] from crates/cargo-horizon-mod/Cargo.toml and downloads the archive this workflow
# attaches to the tag `cargo-horizon-mod-v<version>`, whose version must be the manifest's. The targets come from
# tools/horizon_mod_binstall.py, the same file the manifest's template is checked against, so a platform added there
# is built here and one nobody publishes is caught before it ships. A pull request builds the archives and publishes
# nothing.
on:
push:
tags: ['cargo-horizon-mod-v*']
pull_request:
paths:
- 'crates/cargo-horizon-mod/**'
- 'tools/horizon_mod_binstall.py'
- '.github/workflows/cargo-horizon-mod.yml'
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
needs: plan
name: build ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Build
run: cargo build --release --locked -p cargo-horizon-mod --target ${{ matrix.target }}
- name: It starts
if: matrix.run
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
out=$("$binary" 2>&1 || true)
case "$out" in
*"usage: cargo horizon-mod"*) ;;
*) echo "$out"; echo "::error::$binary does not start"; exit 1 ;;
esac
- name: Package
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
retention-days: 3
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: cargo-horizon-mod-*
path: dist
merge-multiple: true
- name: The tag and the manifest name the same release and archives
run: python3 tools/horizon_mod_binstall.py dist "$GITHUB_REF_NAME"
- name: Checksums
run: |
set -euo pipefail
cd dist
for file in *.tar.gz; do sha256sum "$file" | cut -d' ' -f1 > "$file.sha256"; done
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes="\`cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod\`"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* --latest=false --title "cargo-horizon-mod ${GITHUB_REF_NAME#cargo-horizon-mod-v}" --notes "$notes"
fi
+28 -9
View File
@@ -27,13 +27,18 @@ 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: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
# 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 == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
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-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -63,6 +68,16 @@ jobs:
else
python tools/check_public_tree.py --history
fi
# Our runners as GitHub's: some Python tests compile Rust with a bare `rustc` (GitHub's image has a default
# toolchain, our runners' own rustup homes may not), and the GPU tests find no adapter there, so they return
# early. Ours have Mesa's software Vulkan for parity; a backend Linux lacks keeps the tests from it, so a pull
# request's verify doesn't depend on which pool took it (#8627: a GPU test that flakes on lavapipe).
- name: Our runners as GitHub's (a default Rust toolchain, no GPU)
if: runner.environment != 'github-hosted'
run: |
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -104,7 +119,11 @@ jobs:
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
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-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -145,8 +164,8 @@ jobs:
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
# A git diff of this repository's own branch: one of playhorizon's small runners (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# A git diff of this repository's own branch: one of our small runners (horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
+4 -4
View File
@@ -29,8 +29,8 @@ jobs:
# the skipped runs of ordinary comments must not cancel a waiting one.
concurrency:
group: claim-${{ github.event.issue.number }}
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
@@ -56,8 +56,8 @@ jobs:
run: python3 tools/ci/claim_command.py command
release:
if: github.event_name != 'issue_comment' && github.repository == 'horizon-rust/horizon'
# One of playhorizon's small runners, out of GitHub's shared pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
# One of our small runners (horizon-light), out of GitHub's pool: main's script only (docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/mod-registry.yml'
permissions:
contents: read
concurrency:
group: mod-registry-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: 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:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-mod-registry
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ concurrency:
jobs:
check:
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script, Mojang's public manifest.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
+2 -1
View File
@@ -253,7 +253,8 @@ jobs:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
# One comment, no checkout and no secret: one of our small runners (label horizon-light, docs/ci-runners.md).
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
issues: write
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
concurrency:
group: overlap-note-${{ github.event.pull_request.number }}
cancel-in-progress: true
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
+14 -3
View File
@@ -22,17 +22,24 @@ env:
jobs:
bench:
runs-on: ubuntu-latest
# Our checks runners (label horizon-checks, docs/ci-runners.md): the branch and the base on the same machine, as
# the comparison needs. 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: 90
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust and the ALSA and udev headers
env:
HOSTED: ${{ runner.environment == 'github-hosted' }}
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
if [ "$HOSTED" = true ]; then
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@v2
if: runner.environment == 'github-hosted'
with:
shared-key: pack-bench
- name: Bench the branch
@@ -46,11 +53,15 @@ jobs:
SCALE: ${{ inputs.scale || '1' }}
run: |
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
# Our runners keep the folder beside the checkout between jobs.
git worktree remove --force ../base 2> /dev/null || rm -rf ../base
git worktree prune
git worktree add ../base FETCH_HEAD
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
# A bench row that times a function the base lacks keeps it from building there: then
# the branch's own rows compare the two ways.
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
target=${CARGO_TARGET_DIR:-$GITHUB_WORKSPACE/target}
(cd ../base && CARGO_TARGET_DIR="$target" cargo run --profile fast -p horizon-client-render \
--example pack_load_bench -- --scale "$SCALE") > base.md \
|| echo "The bench does not build on the base." > base.md
- name: Summary
+2 -2
View File
@@ -16,9 +16,9 @@ jobs:
label:
# Title-only edits change nothing: skip them before a runner starts.
if: github.event.action != 'edited' || github.event.changes.body != null
# One of playhorizon's small runners, out of GitHub's shared pool: it runs main's script only (above), so a fork's
# One of our small runners (horizon-light), out of GitHub's pool: it runs main's script only (above), so a fork's
# pull request runs none of its code there (docs/ci-runners.md). SELF_HOSTED_CI=off: GitHub's runners.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "playhorizon-light"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+7 -1
View File
@@ -15,9 +15,13 @@ on:
- '.github/workflows/telemetry-server.yml'
permissions:
contents: read
concurrency:
group: telemetry-server-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
runs-on: 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:
@@ -25,7 +29,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Ours keep their build folder between jobs.
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
workspaces: crates/horizon-telemetry-server
save-if: ${{ github.ref == 'refs/heads/main' }}
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
# One of our small runners (label horizon-light, docs/ci-runners.md): main's script only, as above.
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
timeout-minutes: 5
permissions:
contents: read
+34 -33
View File
@@ -5,7 +5,8 @@ everything is that **it plays exactly like vanilla**. This page is the short ver
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything
else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
@@ -14,23 +15,25 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
Horizon never downloads the game: it prepares its resources from your own copy of Minecraft 26.3, as described in
[Build from source](README.md#build-from-source).
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
(`libasound2-dev libudev-dev`).
- **Resources:** Horizon never downloads the game; it prepares its resources from your own copy of Minecraft 26.3, as
described in [Build from source](README.md#build-from-source).
- **Commands:**
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-client
```
The launcher is its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
## Vanilla first
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here
means Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a
**harness**: a `tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust
probe against Horizon, and compares their outputs. There are about 290 of them.
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
@@ -41,11 +44,10 @@ probe against Horizon, and compares their outputs. There are about 290 of them.
off by default wherever they would change the vanilla look.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
Never commit game files, decompiled source or prepared resources (`.local/`): `tools/check_public_tree.py` rejects
them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
`tools/check_public_tree.py` rejects them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code, or code whose license allows it
@@ -54,24 +56,23 @@ another project's source may also be read and used, **only when that project's l
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative Commons licenses
(CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them), custom or
"source available" licenses, all rights reserved (OptiFine), and any project with no license or an unclear one.
Don't read, copy, translate or paraphrase their source, and don't send code an AI tool wrote from it. Being able
to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one at
your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
## Before asking for review
@@ -87,8 +88,8 @@ your change can affect. Without the vanilla data on your machine it lists those
`--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 library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
including examples. The dependency scope includes optional, platform, build and development dependencies. Tests run
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
## Claim before you start
@@ -123,8 +124,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
`label:parity` is the list of real progress toward vanilla.
- **Release labels** (we set them if you can't): `feature` also puts it under "New" in the release notes,
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
`skip-release` when players see no change (tooling, docs, tests), nothing for a fix. `release-now` is for crash
fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
+3 -3
View File
@@ -7,7 +7,7 @@
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-833%20open-F0795A" alt="Player-visible gaps open"></a>
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
<img src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-2A2470" alt="Linux, macOS and Windows">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3B2F7E" alt="MIT license"></a>
</p>
@@ -122,13 +122,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
+3
View File
@@ -14,6 +14,9 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
The GLSL frontend also resets the common type for each comma-separated declarator so one
variable's array specifier does not change later variables.
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
@@ -0,0 +1,27 @@
# Particle culling review measurements — 2026-11-02
Ordinary-scene captures and measurements for PR #7054. The scene contains 24 mobs, about 9,400–10,100 rain particles, and about 50–100 campfire smoke particles. Two opposite-order passes were run with 2 s warmup and 8 s measurement per condition. Host: Intel Core i7-3770S, Intel HD Graphics 4000, Vulkan/Mesa, 675×727, render distance 4, VSync off, 260 FPS cap.
These are means of the two samples. CPU is the harness's average CPU time per frame; p99 is frame time. The normal scene is not expected to show large gains because most particles are visible.
| Condition | FPS | CPU ms/frame | Frame p99 ms |
| --- | ---: | ---: | ---: |
| Main, culling Off | 88.39 | 1.930 | 23.85 |
| Particle branch, culling Off | 88.55 | 1.828 | 24.88 |
| Main, Entity Culling Synchronous | 89.12 | 1.887 | 23.07 |
| Particle branch, Entity Culling Synchronous, Particle Culling Off | 89.06 | 1.897 | 25.18 |
| Particle Culling Synchronous | 88.87 | 1.774 | 24.94 |
| Particle Culling Asynchronous* | 89.00 | 1.754 | 24.90 |
*The asynchronous samples predate the final switch to a dedicated particle worker. They are retained only as historical context and are not a measurement of the final worker implementation. No FPS improvement is claimed from these ordinary-scene runs. The Off path delegates directly to `render`; `disabled_culled_render_matches_the_regular_particle_path` checks that it produces the same vertices.
The FPS and CPU differences are within run-to-run variability for this GPU-limited scene. The first four conditions are the reviewer's requested Main-versus-branch default and existing synchronous-mode comparisons. Particle Sync/Async rows are branch-internal mode comparisons.
## Captures
The screenshots show the same ordinary test world and FPS overlay:
- `main-off.png`
- `particle-off.png`
- `particle-sync.png`
- `particle-async.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

+8
View File
@@ -6,6 +6,14 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
# `cargo-horizon-mod`'s workflow attaches to the `cargo-horizon-mod-v<version>` release for the runner's target,
# holding the binary at its root. tools/horizon_mod_binstall.py expands it for the workflow's targets.
[package.metadata.binstall]
pkg-url = "https://github.com/horizon-rust/horizon/releases/download/cargo-horizon-mod-v{ version }/{ name }-{ target }.tar.gz"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
+186
View File
@@ -130,6 +130,192 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/lt_lt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lt_lt.json")
}
"assets/horizon/lang/it_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/it_it.json")
}
"assets/horizon/lang/nl_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/nl_nl.json")
}
"assets/horizon/lang/vi_vn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vi_vn.json")
}
"assets/horizon/lang/th_th.json" => {
include_str!("../../horizon-client/assets/horizon/lang/th_th.json")
}
"assets/horizon/lang/ja_jp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ja_jp.json")
}
"assets/horizon/lang/ar_sa.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ar_sa.json")
}
"assets/horizon/lang/pt_br.json" => {
include_str!("../../horizon-client/assets/horizon/lang/pt_br.json")
}
"assets/horizon/lang/cs_cz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cs_cz.json")
}
"assets/horizon/lang/sv_se.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sv_se.json")
}
"assets/horizon/lang/el_gr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/el_gr.json")
}
"assets/horizon/lang/ca_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ca_es.json")
}
"assets/horizon/lang/et_ee.json" => {
include_str!("../../horizon-client/assets/horizon/lang/et_ee.json")
}
"assets/horizon/lang/lv_lv.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lv_lv.json")
}
"assets/horizon/lang/sl_si.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sl_si.json")
}
"assets/horizon/lang/sr_cs.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_cs.json")
}
"assets/horizon/lang/is_is.json" => {
include_str!("../../horizon-client/assets/horizon/lang/is_is.json")
}
"assets/horizon/lang/af_za.json" => {
include_str!("../../horizon-client/assets/horizon/lang/af_za.json")
}
"assets/horizon/lang/ms_my.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ms_my.json")
}
"assets/horizon/lang/be_by.json" => {
include_str!("../../horizon-client/assets/horizon/lang/be_by.json")
}
"assets/horizon/lang/sr_sp.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sr_sp.json")
}
"assets/horizon/lang/az_az.json" => {
include_str!("../../horizon-client/assets/horizon/lang/az_az.json")
}
"assets/horizon/lang/uz_uz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uz_uz.json")
}
"assets/horizon/lang/kk_kz.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kk_kz.json")
}
"assets/horizon/lang/ka_ge.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ka_ge.json")
}
"assets/horizon/lang/hy_am.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hy_am.json")
}
"assets/horizon/lang/bs_ba.json" => {
include_str!("../../horizon-client/assets/horizon/lang/bs_ba.json")
}
"assets/horizon/lang/mk_mk.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mk_mk.json")
}
"assets/horizon/lang/sq_al.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sq_al.json")
}
"assets/horizon/lang/cy_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cy_gb.json")
}
"assets/horizon/lang/ga_ie.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ga_ie.json")
}
"assets/horizon/lang/eu_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eu_es.json")
}
"assets/horizon/lang/gl_es.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gl_es.json")
}
"assets/horizon/lang/fil_ph.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fil_ph.json")
}
"assets/horizon/lang/ta_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ta_in.json")
}
"assets/horizon/lang/mn_mn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mn_mn.json")
}
"assets/horizon/lang/lo_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lo_la.json")
}
"assets/horizon/lang/so_so.json" => {
include_str!("../../horizon-client/assets/horizon/lang/so_so.json")
}
"assets/horizon/lang/fy_nl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fy_nl.json")
}
"assets/horizon/lang/gd_gb.json" => {
include_str!("../../horizon-client/assets/horizon/lang/gd_gb.json")
}
"assets/horizon/lang/lb_lu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lb_lu.json")
}
"assets/horizon/lang/ky_kg.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ky_kg.json")
}
"assets/horizon/lang/kn_in.json" => {
include_str!("../../horizon-client/assets/horizon/lang/kn_in.json")
}
"assets/horizon/lang/mt_mt.json" => {
include_str!("../../horizon-client/assets/horizon/lang/mt_mt.json")
}
"assets/horizon/lang/eo_uy.json" => {
include_str!("../../horizon-client/assets/horizon/lang/eo_uy.json")
}
"assets/horizon/lang/haw_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/haw_us.json")
}
"assets/horizon/lang/ig_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ig_ng.json")
}
"assets/horizon/lang/la_la.json" => {
include_str!("../../horizon-client/assets/horizon/lang/la_la.json")
}
"assets/horizon/lang/tt_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tt_ru.json")
}
"assets/horizon/lang/yi_de.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yi_de.json")
}
"assets/horizon/lang/yo_ng.json" => {
include_str!("../../horizon-client/assets/horizon/lang/yo_ng.json")
}
"assets/horizon/lang/ba_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ba_ru.json")
}
"assets/horizon/lang/br_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/br_fr.json")
}
"assets/horizon/lang/cv_cu.json" => {
include_str!("../../horizon-client/assets/horizon/lang/cv_cu.json")
}
"assets/horizon/lang/fo_fo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fo_fo.json")
}
"assets/horizon/lang/fur_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fur_it.json")
}
"assets/horizon/lang/li_li.json" => {
include_str!("../../horizon-client/assets/horizon/lang/li_li.json")
}
"assets/horizon/lang/lmo.json" => {
include_str!("../../horizon-client/assets/horizon/lang/lmo.json")
}
"assets/horizon/lang/oc_fr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/oc_fr.json")
}
"assets/horizon/lang/sah_sah.json" => {
include_str!("../../horizon-client/assets/horizon/lang/sah_sah.json")
}
"assets/horizon/lang/se_no.json" => {
include_str!("../../horizon-client/assets/horizon/lang/se_no.json")
}
"assets/horizon/lang/szl.json" => {
include_str!("../../horizon-client/assets/horizon/lang/szl.json")
}
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
@@ -173,6 +173,16 @@ impl Registries {
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
if self.summonable.is_empty() {
self.summonable = strings(&r["summonable"]);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types = strings(&r["entity_types"]);
self.entity_types.sort();
self.entity_types.dedup();
}
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
@@ -236,6 +246,16 @@ impl Registries {
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
if self.summonable.is_empty() {
self.summonable.clone_from(&e.summonable);
self.summonable.sort();
self.summonable.dedup();
}
if self.entity_types.is_empty() {
self.entity_types.clone_from(&e.entity_types);
self.entity_types.sort();
self.entity_types.dedup();
}
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
@@ -64,6 +64,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
@@ -0,0 +1,827 @@
//! Draws a scene `horizon-ctl render-capture DIR` wrote, offscreen, from its camera, with the
//! real `Renderer`, and prints GPU and CPU milliseconds per frame and Horizon's own passes'
//! GPU times as JSON (docs/graphics.md, "Render capture and replay").
//! `cargo run --release -p horizon-client-render --example scene_replay -- DIR [--frames 300]
//! [--warmup 60] [--yaw-offsets 0,90,180,270] [--texture-filtering 0|1|2] [--anisotropy-bit N]
//! [--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]`
//! `--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.
//! `--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::{
assets::{Assets, RenderAssets},
biome::Biomes,
blocks::BlockFacts,
chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, block_strategy},
fast_hash::{FxHashMap, FxHashSet},
gfx::{
PreparedTextures, Renderer, ScreenshotRequest,
capture::{self, AtlasCheck, Scene},
panorama::Faces,
},
graphics::LeafCullingMode,
hud::HudBatch,
lod::TileKey,
mesher::{MeshScratch, MeshTables, Mesher, SectionMesh, packed},
models,
};
use horizon_core::{
biome::{BiomeZoom, obfuscate_seed},
paletted::PalettedContainer,
};
use horizon_world::{
chunk::BlockState,
nbt::{Limits, NamedTag, Tag},
region::RegionFile,
registry::BlockRegistry,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
// 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::Allocator = horizon_threads::ALLOCATOR;
#[derive(Default)]
struct Options {
dir: PathBuf,
frames: usize,
warmup: usize,
yaw_offsets: Vec<f32>,
root: Option<PathBuf>,
texture_filtering: Option<i32>,
anisotropy_bit: Option<i32>,
mipmaps: Option<u32>,
lod_distance: Option<u32>,
render_distance: Option<i32>,
size: Option<[u32; 2]>,
flat: Option<bool>,
cull: Option<bool>,
sides: Option<bool>,
gpu_culling: Option<bool>,
screenshot: Option<PathBuf>,
world: Option<PathBuf>,
leaf_culling: LeafCullingMode,
memory: bool,
}
fn on_off(value: &str) -> Result<bool, String> {
match value {
"on" | "flat" => Ok(true),
"off" | "indirect" => Ok(false),
other => Err(format!("expected on or off, got {other}")),
}
}
fn options() -> Result<Options, String> {
let mut o = Options {
frames: 300,
warmup: 60,
yaw_offsets: vec![0.0],
..Default::default()
};
let mut args = std::env::args().skip(1);
let mut dir = None;
while let Some(arg) = args.next() {
if !arg.starts_with("--") {
dir = Some(PathBuf::from(arg));
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |v: &str| v.parse::<i64>().map_err(|e| format!("{arg}: {e}"));
match arg.as_str() {
"--frames" => o.frames = number(&value)?.max(1) as usize,
"--warmup" => o.warmup = number(&value)?.max(0) as usize,
"--yaw-offsets" => {
o.yaw_offsets = value
.split(',')
.map(|v| v.trim().parse::<f32>().map_err(|e| format!("{arg}: {e}")))
.collect::<Result<_, _>>()?
}
"--root" => o.root = Some(value.into()),
"--texture-filtering" => o.texture_filtering = Some(number(&value)? as i32),
"--anisotropy-bit" => o.anisotropy_bit = Some(number(&value)? as i32),
"--mipmaps" => o.mipmaps = Some(number(&value)?.max(0) as u32),
"--lod-distance" => o.lod_distance = Some(number(&value)?.max(0) as u32),
"--render-distance" => o.render_distance = Some(number(&value)?.max(0) as i32),
"--size" => {
let (w, h) = value.split_once('x').ok_or("--size takes WxH")?;
o.size = Some([number(w)? as u32, number(h)? as u32]);
}
"--section-draws" => o.flat = Some(on_off(&value)?),
"--section-cull" => o.cull = Some(on_off(&value)?),
"--section-sides" => o.sides = Some(on_off(&value)?),
"--gpu-culling" => o.gpu_culling = Some(on_off(&value)?),
"--screenshot" => o.screenshot = Some(value.into()),
"--world" => o.world = Some(value.into()),
"--memory" => o.memory = on_off(&value)?,
"--leaf-culling" => {
o.leaf_culling = LeafCullingMode::ALL
.into_iter()
.find(|mode| mode.name() == value)
.ok_or(format!("no leaf culling mode {value}"))?;
}
other => return Err(format!("unknown option {other}")),
}
}
o.dir = dir.ok_or("the capture directory is required")?;
Ok(o)
}
/// Milliseconds: average, median, 95th percentile and slowest, over `samples`.
fn summary(samples: &[f32]) -> Value {
if samples.is_empty() {
return Value::Null;
}
let mut sorted = samples.to_vec();
sorted.sort_by(f32::total_cmp);
let at = |q: f32| sorted[((sorted.len() - 1) as f32 * q).round() as usize];
json!({
"avg": samples.iter().sum::<f32>() / samples.len() as f32,
"min": sorted[0],
"p10": at(0.1),
"p50": at(0.5),
"p95": at(0.95),
"max": sorted[sorted.len() - 1],
"samples": samples.len(),
})
}
fn ms(d: Duration) -> f32 {
d.as_secs_f32() * 1000.0
}
fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
let o = options()?;
let scene = Scene::read(&o.dir)?;
let settings = &scene.settings;
let root = o.root.clone().unwrap_or_else(|| settings.resources.clone());
let loading = Instant::now();
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
if AtlasCheck::of(&render) != settings.atlas {
return Err(format!(
"the block sprites at {} are not the ones the capture was made with (resource packs \
or mods?): pass --root with the game's resource root",
root.display()
)
.into());
}
let one = [255u8; 4];
let zero = [0u8; 4];
let prepared = PreparedTextures::new(
&render.pixels,
render.sprite_size,
render.texture_layers,
&render.sprite_mips,
(&zero[..], 1),
)
.with_pbr(
&render.pbr_normals,
&render.pbr_specular,
render.pbr_size,
render.texture_layers,
);
let mut renderer = pollster::block_on(Renderer::new(
window,
true,
&render.pixels,
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
&render.sprite_mips,
&render.animations,
(&one[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1, 1),
(&zero[..], 1),
Arc::new(Faces::colour([0, 0, 0, 255])),
Some(prepared),
None,
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
renderer.resize(width, height);
renderer.gamma = state.gamma;
renderer.fullbright = state.fullbright;
renderer.improved_transparency = state.improved_transparency;
renderer.animated_textures = state.animated_textures;
let filtering = o.texture_filtering.unwrap_or(settings.texture_filtering);
let anisotropy = o.anisotropy_bit.unwrap_or(settings.anisotropy_bit);
renderer.set_texture_filtering(filtering, anisotropy);
renderer.set_mipmap_levels(o.mipmaps.unwrap_or(settings.mipmap_levels));
renderer.set_world_post(
settings.resolution_scale,
settings.fxaa,
settings.taa,
settings.upscale,
);
let mut notes = Vec::new();
let mut note = |what: &str, result: horizon_client_render::Result<()>| {
if let Err(e) = result {
notes.push(format!("{what}: {e}"));
}
};
note(
"section-draws",
renderer.set_flat_draws(o.flat.unwrap_or(state.flat_draws)),
);
note(
"section-cull",
renderer.set_section_cull(o.cull.unwrap_or(state.section_cull)),
);
note(
"gpu-culling",
renderer.set_gpu_culling(o.gpu_culling.unwrap_or(state.gpu_culling)),
);
renderer.set_section_sides(o.sides.unwrap_or(state.section_sides));
renderer.set_pass_timing(true);
let layout = ColumnLayout::of_level(state.min_y, state.sections as i32 * 16);
if layout != ColumnLayout::VANILLA {
renderer.clear_sections(layout);
}
// Near sections first: with no far tile resident yet, none fades in.
let camera = scene.frame.camera;
let column = |x: f64| (x.floor() as i32) >> 4;
let (cx, cz) = (column(camera[0]), column(camera[2]));
let near = |x: i32, z: i32| {
o.render_distance
.is_none_or(|r| (x - cx).pow(2) + (z - cz).pow(2) <= r * r)
};
let (mut remeshed, mut world_report) = (FxHashMap::default(), Value::Null);
if let Some(world) = &o.world {
let keys: Vec<[i32; 3]> = section_keys(&o.dir)?
.into_iter()
.filter(|&[x, _, z]| near(x, z))
.collect();
let started = Instant::now();
remeshed = remesh(world, &root, &assets, &render, o.leaf_culling, &keys)?;
world_report = json!({
"save": world.display().to_string(),
"leaf_culling": o.leaf_culling.name(),
"remeshed": remeshed.len(),
"kept": keys.len() - remeshed.len(),
"remesh_ms": ms(started.elapsed()),
});
}
let leaf_layers = leaf_layers(&render);
let (mut sections, mut quads, mut leaf_quads) = (0, 0usize, 0usize);
let mut packed_quads = 0usize;
capture::read_meshes(&o.dir, "sections", |[x, y, z], captured| {
if near(x, z) {
let mesh = remeshed.remove(&[x, y, z]).unwrap_or(captured);
quads += mesh.solid.len() / 4;
packed_quads += packed::packed_quads(&mesh.packed, mesh.solid.len() / 4);
leaf_quads += mesh
.solid
.iter()
.filter(|v| leaf_layers.contains(&v.layer))
.count()
/ 4;
renderer.upload((x, y, z), &mesh);
sections += 1;
}
})?;
let mut tiles: Vec<(TileKey, SectionMesh)> = Vec::new();
capture::read_meshes(&o.dir, "tiles", |[x, z, level], mesh| {
let key = TileKey {
x,
z,
level: level as u8,
};
let [ox, _, oz] = key.origin();
let half = f64::from(key.size()) / 2.0;
let (dx, dz) = (
f64::from(ox) + half - camera[0],
f64::from(oz) + half - camera[2],
);
let near = o
.lod_distance
.is_none_or(|d| (dx * dx + dz * dz).sqrt() <= f64::from(d) * 16.0);
if near {
tiles.push((key, mesh));
}
})?;
let tile_count = tiles.len();
if !tiles.is_empty() {
let cutout: FxHashSet<(i32, i32)> = scene.lod_cutout.iter().copied().collect();
renderer.queue_lods(tiles, Vec::new(), Some(cutout));
renderer.upload_lods(Duration::from_secs(3600));
}
let loaded_ms = ms(loading.elapsed());
let hud = HudBatch::new([width, height]);
let mut views = Vec::new();
for &offset in &o.yaw_offsets {
let mut frame = scene.frame(&hud);
frame.yaw += offset;
frame.eye_rotation[0] += offset;
if let Some(r) = o.render_distance {
frame.distance = (r * 16) as f32;
frame.detail_distance = frame.distance;
}
let draw = |renderer: &mut Renderer| -> horizon_client_render::Result<Duration> {
if frame.cave_culling {
renderer.start_occlusion(frame.camera, frame.distance);
}
let started = Instant::now();
renderer.render(&frame)?;
Ok(started.elapsed())
};
for _ in 0..o.warmup {
draw(&mut renderer)?;
}
renderer.gpu_times.clear();
let (mut call, mut encode, mut gpu) = (Vec::new(), Vec::new(), Vec::new());
for _ in 0..o.frames {
call.push(ms(draw(&mut renderer)?));
encode.push(ms(renderer.last_encode));
gpu.append(&mut renderer.gpu_times);
}
// Timestamps arrive a few frames late: a few more of the same frame bring them in.
for _ in 0..16 {
if gpu.len() >= o.frames {
break;
}
draw(&mut renderer)?;
gpu.append(&mut renderer.gpu_times);
}
gpu.truncate(o.frames);
if let Some(path) = o.screenshot.as_ref().filter(|_| views.is_empty()) {
renderer.screenshot_result = None;
renderer.screenshot = Some(ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
if renderer.screenshot_result.is_some() {
break;
}
}
match renderer.screenshot_result.take() {
Some(Ok(_)) => {}
Some(Err(e)) => notes.push(format!("screenshot: {e}")),
None => notes.push("screenshot: not saved".into()),
}
}
views.push(json!({
"yaw_offset": offset,
"gpu_ms": summary(&gpu),
"render_call_ms": summary(&call),
"cpu_encode_ms": summary(&encode),
"gpu_passes_median_ms": renderer
.own_pass_times()
.into_iter()
.map(|(name, ms)| (name, json!(ms)))
.collect::<serde_json::Map<_, _>>(),
"drawn_sections": renderer.drawn_sections,
"section_draws": renderer.section_draws,
"section_draw_calls": renderer.section_draw_calls,
// Occlusion culling's last result: boxes hidden, boxes tested, share of solid
// indices hidden.
"culled": renderer.culled_sections(),
}));
}
let section_draws = if renderer.flat_draws() {
"flat"
} else {
"indirect"
};
let memory = o.memory.then(|| memory_report(&renderer));
let report = json!({
"capture": o.dir.display().to_string(),
"adapter": renderer.info.name,
"captured_on": state.adapter,
"size": [width, height],
"sections": sections,
"solid_quads": quads,
"leaf_quads": leaf_quads,
// Solid quads uploaded as one record each (`mesher::packed`).
"packed_quads": packed_quads,
"world": world_report,
"tiles": tile_count,
"load_ms": loaded_ms,
"frames": o.frames,
"warmup": o.warmup,
"settings": {
"texture_filtering": filtering,
"anisotropy_bit": anisotropy,
"mipmaps": o.mipmaps.unwrap_or(settings.mipmap_levels),
"lod_distance": o.lod_distance,
"render_distance": o.render_distance,
"section_draws": section_draws,
"section_cull": renderer.section_cull(),
"section_sides": renderer.section_sides(),
"gpu_culling": renderer.gpu_culling(),
"shader_pack_left_out": state.shader_pack,
},
"notes": notes,
"views": views,
"memory": memory,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn mb(bytes: u64) -> f64 {
(bytes as f64 / 1048576.0 * 10.0).round() / 10.0
}
/// `--memory`: the renderer's census in MiB and, on macOS, `footprint`'s summary of this
/// process (GPU buffers and textures count as IOAccelerator memory there).
fn memory_report(renderer: &Renderer) -> Value {
let gpu: serde_json::Map<String, Value> = renderer
.gpu_census()
.into_iter()
.map(|(name, bytes)| (format!("{name}_mb"), json!(mb(bytes))))
.collect();
let footprint = std::process::Command::new("/usr/bin/footprint")
.args(["-f", "bytes", "-p", &std::process::id().to_string()])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
let footprint = footprint.map(|text| {
let mut total = None;
let mut categories = Vec::new();
for line in text.lines() {
if let Some(at) = line.find("Footprint: ") {
total = line[at + 11..]
.split_whitespace()
.next()
.and_then(|v| v.parse::<u64>().ok());
}
// " <dirty> B <clean> B <reclaimable> B <regions> <category>"
let words: Vec<&str> = line.split_whitespace().collect();
if words.len() >= 8
&& words[1] == "B"
&& let Ok(dirty) = words[0].parse::<u64>()
&& dirty >= 1 << 20
{
categories.push(json!([words[7..].join(" "), mb(dirty)]));
}
}
json!({ "total_mb": total.map(mb), "dirty_by_category_mb": categories })
});
json!({ "gpu": gpu, "footprint": footprint })
}
struct App;
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let window = Arc::new(
event_loop
.create_window(Window::default_attributes().with_visible(false))
.unwrap(),
);
if let Err(error) = replay(window) {
eprintln!("scene_replay: {error}");
std::process::exit(1);
}
event_loop.exit();
}
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
fn main() {
EventLoop::new().unwrap().run_app(&mut App).unwrap();
}
/// The captured sections' keys, from the index alone.
fn section_keys(dir: &Path) -> Result<Vec<[i32; 3]>, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize)]
struct Key {
key: [i32; 3],
}
let records: Vec<Key> = serde_json::from_reader(std::io::BufReader::new(std::fs::File::open(
dir.join("sections.json"),
)?))?;
Ok(records.into_iter().map(|r| r.key).collect())
}
/// Sprite layers of every leaves block, as terrain vertices carry them (with or
/// without `OPAQUE_LAYER`).
fn leaf_layers(render: &RenderAssets) -> FxHashSet<u16> {
render
.sprite_layers
.iter()
.filter(|(name, _)| name.ends_with("_leaves"))
.flat_map(|(_, &layer)| {
[
layer as u16,
layer as u16 | horizon_client_render::mesher::OPAQUE_LAYER,
]
})
.collect()
}
/// `--world`: the sections `keys` meshed again from `save`'s overworld region
/// files, on every core, as the game's workers mesh them.
fn remesh(
save: &Path,
root: &Path,
assets: &Assets,
render: &RenderAssets,
leaf_culling: LeafCullingMode,
keys: &[[i32; 3]],
) -> Result<FxHashMap<[i32; 3], SectionMesh>, Box<dyn std::error::Error>> {
let facts = BlockFacts::load(root, assets.registry)?;
let visibility = models::load_visibility(root, assets.registry)?;
let tables = MeshTables::new(assets, render, &facts, visibility)?;
let biomes = Biomes::load(root)?;
let seed = world_seed(save).unwrap_or_else(|| {
eprintln!(
"scene_replay: no seed in {}, biome blending uses 0",
save.display()
);
0
});
let options = std::fs::read_to_string("options.txt").unwrap_or_default();
let option = |key: &str| {
options
.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix(':').map(str::to_owned))
};
let smooth_lighting = option("ao").is_none_or(|v| v == "true");
let cutout_leaves = option("cutoutLeaves").is_none_or(|v| v == "true");
let blend_radius = option("biomeBlendRadius")
.and_then(|v| v.parse().ok())
.unwrap_or(2);
let mut columns: Vec<(i32, i32)> = keys
.iter()
.flat_map(|&[x, _, z]| {
(-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)))
})
.collect();
columns.sort_unstable();
columns.dedup();
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let region_dir = save.join("dimensions/minecraft/overworld/region");
let next = AtomicUsize::new(0);
let chunks: FxHashMap<(i32, i32), Chunk> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut files: FxHashMap<(i32, i32), Option<RegionFile>> = FxHashMap::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&(x, z)) = columns.get(i) else {
break;
};
let file = files.entry((x >> 5, z >> 5)).or_insert_with(|| {
RegionFile::open(region_dir.join(format!(
"r.{}.{}.mca",
x >> 5,
z >> 5
)))
.ok()
});
let root = file
.as_mut()
.and_then(|f| {
f.read(horizon_core::position::ChunkPos::new(x, z))
.ok()
.flatten()
})
.and_then(|record| record.nbt(Limits::default()).ok());
if let Some(chunk) =
root.and_then(|root| decode(&root, assets.registry, &biomes))
{
out.push(((x, z), chunk));
}
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect()
});
let meshable: Vec<[i32; 3]> = keys
.iter()
.copied()
.filter(|&[x, _, z]| {
(-1..=1).all(|dz| (-1..=1).all(|dx| chunks.contains_key(&(x + dx, z + dz))))
})
.collect();
let next = AtomicUsize::new(0);
let meshes = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables: &tables,
biomes: &biomes,
visibility: tables.visibility.clone(),
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling,
};
let mut scratch = MeshScratch::default();
let mut out = Vec::new();
loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(&[x, y, z]) = meshable.get(i) else {
break;
};
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
})
});
let mut mesh =
mesher.mesh_reusing(&region, y, SectionMesh::default(), &mut scratch);
mesh.sort_solid();
out.push(([x, y, z], mesh));
}
out
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect::<FxHashMap<_, _>>()
});
Ok(meshes)
}
/// The world seed: `data/minecraft/world_gen_settings.dat`, or level.dat's
/// `Data.WorldGenSettings` in older saves.
fn world_seed(save: &Path) -> Option<i64> {
let read = |path: PathBuf| {
horizon_world::datafix::files::read_dat(&path)
.ok()
.flatten()
};
let seed = |tag: Option<&Tag>| match tag?.get("seed")? {
Tag::Long(seed) => Some(*seed),
_ => None,
};
seed(
read(save.join("data/minecraft/world_gen_settings.dat"))
.as_ref()
.and_then(|t| t.get("data")),
)
.or_else(|| {
seed(
read(save.join("level.dat"))?
.get("Data")?
.get("WorldGenSettings"),
)
})
}
/// A saved full chunk's blocks, biomes and light, as the client's own save
/// loader reads them (`save::decode_lit`, in horizon-client).
fn decode(root: &NamedTag, registry: &BlockRegistry, biomes: &Biomes) -> Option<Chunk> {
let tag = &root.tag;
let text = |t: Option<&Tag>| match t {
Some(Tag::String(s)) => Some(s.to_string_lossy().to_string()),
_ => None,
};
if text(tag.get("Status")).is_some_and(|s| s != "minecraft:full" && s != "full") {
return None;
}
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
return None;
}
let (Some(Tag::Int(x)), Some(Tag::Int(z))) = (tag.get("xPos"), tag.get("zPos")) else {
return None;
};
let mut chunk = Chunk::with_layout(*x, *z, ColumnLayout::VANILLA);
let air = registry.default_state("minecraft:air")?.0;
let Some(Tag::List(sections)) = tag.get("sections") else {
return Some(chunk);
};
let longs = |t: Option<&Tag>| match t {
Some(Tag::LongArray(data)) => Some(data.iter().map(|&w| w as u64).collect::<Vec<_>>()),
_ => None,
};
let mut layers = vec![(None, None); chunk.sections.len()];
for section in sections {
let Some(Tag::Byte(y)) = section.get("Y") else {
continue;
};
let Some(si) = chunk.section_index(i32::from(*y) * 16) else {
continue;
};
let target = Arc::make_mut(&mut chunk.sections[si]);
if let Some(states) = section.get("block_states")
&& let Some(Tag::List(palette)) = states.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| {
BlockState::from_nbt(p)
.ok()
.and_then(|s| {
registry.id(&s).or_else(|| {
let default = registry.default_state(&s.name)?;
let mut full = registry.state(default)?.clone();
for (k, v) in &s.properties {
if full.properties.contains_key(k) {
full.properties.insert(k.clone(), v.clone());
}
}
registry.id(&full).or(Some(default))
})
})
.map_or(air, |id| id.0)
})
.collect();
let data = longs(states.get("data"));
if let Ok(blocks) =
PalettedContainer::from_packed(block_strategy(), palette, data.as_deref())
{
target.blocks = blocks;
}
}
if let Some(b) = section.get("biomes")
&& let Some(Tag::List(palette)) = b.get("palette")
{
let palette: Vec<u32> = palette
.iter()
.map(|p| match p {
Tag::String(s) => u32::from(biomes.id(&s.to_string_lossy())),
_ => 0,
})
.collect();
let data = longs(b.get("data"));
if let Ok(container) =
PalettedContainer::from_packed(BIOME_STRATEGY, palette, data.as_deref())
{
target.biomes = container;
}
}
let layer = |name: &str| match section.get(name) {
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(a.clone()),
_ => None,
};
layers[si] = (layer("SkyLight"), layer("BlockLight"));
}
// `save::load_light`: a section without saved sky light takes the light
// of the blocks above it, from the top of the column down.
let mut above = [15u8; 256];
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
let mut data = Box::new([0u8; 4096]);
for (i, v) in data.iter_mut().enumerate() {
let nibble =
|layer: &Option<Vec<u8>>| layer.as_ref().map(|l| l[i >> 1] >> ((i & 1) << 2) & 15);
let s = nibble(&sky).unwrap_or(above[i & 255]);
let b = nibble(&block).unwrap_or(0);
*v = s << 4 | b;
}
if sky.is_some() {
for (i, a) in above.iter_mut().enumerate() {
*a = data[i] >> 4;
}
}
let section = Arc::make_mut(&mut chunk.sections[si]);
section.light = Light::Full(data);
section.compact();
}
chunk.lit = true;
chunk.light_loaded = true;
Some(chunk)
}
@@ -121,6 +121,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
@@ -377,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
+37 -5
View File
@@ -207,6 +207,20 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] with the model library already parsed.
///
/// Parsing it reads every model file under `assets/`, which needs no
/// block registry, so a caller that has the files to spare can start
/// it before `Assets::load` finishes and hand the result in here. On
/// a cold cache that is most of a second of reading moved off the
/// critical path.
pub fn reload_with_models(
&self,
root: &Path,
parsed: crate::model_library::ParsedModels,
) -> Result<RenderAssets> {
RenderAssets::load_with_parsed(root, self, true, Some(parsed))
}
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
@@ -218,6 +232,16 @@ impl RenderAssets {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
Self::load_with_parsed(root, assets, draws, None)
}
/// `parsed`: the model library when a caller parsed it ahead of this
/// (see [`Assets::reload_with_models`]); otherwise it is parsed here.
fn load_with_parsed(
root: &Path,
assets: &Assets,
draws: bool,
parsed: Option<crate::model_library::ParsedModels>,
) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
@@ -225,17 +249,24 @@ impl RenderAssets {
}
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
let parsed = match parsed {
Some(parsed) => parsed,
None if draws => {
let _step = crate::spans::startup::step("core.models_parse");
crate::model_library::ParsedModels::load(root)
}
None => crate::model_library::ParsedModels::default(),
};
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
let model_library = {
let _step = crate::spans::startup::step("core.item_library");
parsed.into_item_library()
};
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
for name in [
@@ -251,6 +282,7 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
} else {
Default::default()
@@ -379,14 +379,17 @@ pub fn generate(
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
let key = argb(&key);
let target = argb(&target);
apply_palette(&mut mapped, &key, &target)?;
let out: Vec<u8> = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
drop(mapped);
Some((w, h, out))
}
}
+11 -8
View File
@@ -61,6 +61,8 @@ struct Mixer {
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
voices: Vec<(ChannelId, [f32; 3], f64)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -544,9 +546,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
mixer.cameras.clear();
mixer.cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
@@ -565,10 +566,11 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
)
}),
);
let shared = cameras.len() > 1;
let shared = mixer.cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
@@ -586,7 +588,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
@@ -617,8 +620,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
for (id, [left, right, gain], step) in voices.iter().copied() {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
@@ -648,6 +650,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
c.position += step;
}
}
mixer.voices = voices;
// Limit the whole buffer together, preserving waveform and stereo balance
// instead of flattening each overloaded sample; release over 200 ms
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
+104 -11
View File
@@ -101,6 +101,22 @@ impl AuthSession {
let _ = std::fs::remove_file(path);
Self::parse(&text.map_err(|e| format!("{}: {e}", path.display()))?)
}
/// The access token's expiry (its JWT `exp`) has passed, or is a minute away: the session
/// service would refuse it. A token that doesn't read as a JWT counts as good: the server
/// says.
pub fn expired(&self) -> bool {
let exp = self
.access_token
.split('.')
.nth(1)
.and_then(|p| crate::skins::base64_decode(&p.replace('-', "+").replace('_', "/")))
.and_then(|json| serde_json::from_slice::<serde_json::Value>(&json).ok())
.and_then(|v| v.get("exp")?.as_u64());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
exp.is_some_and(|exp| exp <= now + 60)
}
}
static CURRENT: OnceLock<Arc<AuthSession>> = OnceLock::new();
@@ -436,11 +452,8 @@ struct ChatKeys {
fetching: bool,
next_try: Option<std::time::Instant>,
}
static CHAT_KEYS: Mutex<ChatKeys> = Mutex::new(ChatKeys {
current: None,
fetching: false,
next_try: None,
});
static CHAT_KEYS: std::sync::LazyLock<Mutex<std::collections::HashMap<u128, ChatKeys>>> =
std::sync::LazyLock::new(|| Mutex::new(std::collections::HashMap::new()));
/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
/// then prepareKeyPair: start fetching when there is no key pair or it is
@@ -449,7 +462,14 @@ pub fn prepare_chat_keys(now: bool) {
let Some(session) = current() else {
return;
};
let mut keys = CHAT_KEYS.lock().unwrap();
prepare_chat_keys_for(session, now);
}
/// Prepare keys for this player, independently of the other local accounts.
pub fn prepare_chat_keys_for(session: Arc<AuthSession>, now: bool) {
let uuid = session.uuid;
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
let instant = std::time::Instant::now();
if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
return;
@@ -463,12 +483,13 @@ pub fn prepare_chat_keys(now: bool) {
return;
}
keys.fetching = true;
drop(keys);
drop(accounts);
let spawned = std::thread::Builder::new()
.name("Profile key fetch".into())
.spawn(move || {
let result = fetch_chat_keys(&session);
let mut keys = CHAT_KEYS.lock().unwrap();
let mut accounts = CHAT_KEYS.lock().unwrap();
let keys = accounts.entry(uuid).or_default();
keys.fetching = false;
match result {
Ok(pair) => keys.current = Some(Arc::new(pair)),
@@ -476,19 +497,24 @@ pub fn prepare_chat_keys(now: bool) {
}
});
if spawned.is_err() {
CHAT_KEYS.lock().unwrap().fetching = false;
CHAT_KEYS.lock().unwrap().entry(uuid).or_default().fetching = false;
}
}
/// The chat key pair fetched so far, unless it has expired.
pub fn chat_keys() -> Option<Arc<ChatKeyPair>> {
let now = now_millis();
chat_keys_for(current()?.uuid)
}
/// This account's key pair; another local player's keys can never be returned.
pub fn chat_keys_for(uuid: u128) -> Option<Arc<ChatKeyPair>> {
CHAT_KEYS
.lock()
.unwrap()
.get(&uuid)?
.current
.clone()
.filter(|k| !k.public.has_expired(now))
.filter(|k| !k.public.has_expired(now_millis()))
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
@@ -632,6 +658,42 @@ mod tests {
}
}
/// base64url without padding, as a JWT's parts are.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n =
chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
for k in 0..=chunk.len() {
out.push(char::from(ALPHABET[(n >> (18 - 6 * k) & 63) as usize]));
}
}
out
}
#[test]
fn a_token_past_its_exp_is_expired() {
// header.payload.signature, the payload base64url without padding.
let token = |exp: u64| {
let json = format!(r#"{{"exp":{exp}}}"#);
let payload = base64url(json.as_bytes());
AuthSession::new("Ann", UUID, &format!("e30.{payload}.sig"))
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(token(now - 10).expired());
assert!(
token(now + 30).expired(),
"a minute from its end: as good as gone"
);
assert!(!token(now + 3600).expired());
assert!(!AuthSession::new("Ann", UUID, "not-a-jwt").expired());
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -795,6 +857,37 @@ mod tests {
.to_string()
}
#[test]
fn local_accounts_use_their_own_chat_keys() {
let pair = |signature: &[u8]| {
let mut keys = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
keys.public.expires_at = i64::MAX;
keys.refreshed_after = i64::MAX;
keys.public.signature = signature.to_vec();
Arc::new(keys)
};
let ann = pair(b"ann");
let bea = pair(b"bea");
{
let mut accounts = CHAT_KEYS.lock().unwrap();
for (uuid, keys) in [(101, ann.clone()), (102, bea.clone())] {
accounts.insert(
uuid,
ChatKeys {
current: Some(keys),
..Default::default()
},
);
}
}
prepare_chat_keys_for(Arc::new(AuthSession::new("Ann", 101, "unused")), true);
assert!(Arc::ptr_eq(&chat_keys_for(101).unwrap(), &ann));
assert!(Arc::ptr_eq(&chat_keys_for(102).unwrap(), &bea));
assert!(chat_keys_for(103).is_none());
CHAT_KEYS.lock().unwrap().remove(&101);
CHAT_KEYS.lock().unwrap().remove(&102);
}
#[test]
fn certificates_response() {
let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
+20 -19
View File
@@ -50,8 +50,9 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) spherical: f32,
@location(3) cylindrical: f32,
// The distances fog reads: .x spherical, .y cylindrical. One vec2 rather than two floats, so that
// the fragment stage stays within 14 user-defined inputs plus front_facing (GL's limit of 15, #8195).
@location(2) fog_distance: vec2<f32>,
// entity.vsh's overlayColor: the OverlayTexture texel (see overlay_texel).
@location(5) overlay_color: vec4<f32>,
@location(4) @interpolate(flat) overlay: u32,
@@ -157,11 +158,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
out.back_color = back;
out.overlay_color = overlay_texel(overlay);
// Sky geometry (clouds, celestial bodies) is exempt from render-distance fog.
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.fog_distance.x = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.fog_distance.y = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
if overlay == 4u { out.fog_distance.x = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@@ -293,7 +294,7 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
color = vec4(c, 1.0);
} else if in.overlay == 4u {
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
@@ -312,8 +313,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -327,8 +328,8 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -347,8 +348,8 @@ fn shadow_color(in: Out) -> vec4<f32> {
let texel = textureSample(atlas, atlas_sampler, uv);
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
@@ -379,7 +380,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// HDR lighting on clouds, drawn again after them with only alpha written: they're lit by the
// sky, so they take its value, which their blending would otherwise cover.
@fragment fn fs_hdr_cloud_mark(in: Out) -> @location(0) vec4<f32> {
let a = in.color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y));
let a = in.color.a * (1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y));
if a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(globals.hdr_light.y));
}
@@ -388,8 +389,8 @@ fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let fog = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
@@ -428,8 +429,8 @@ fn energy_swirl_base(in: Out) -> vec4<f32> {
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
@@ -572,7 +573,7 @@ fn weather_color(in: Out) -> vec4<f32> {
var color = textureLoad(atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@@ -120,7 +120,7 @@ pub struct Environment {
tracks: HashMap<String, Track>,
base: HashMap<String, [f32; 4]>,
}
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Sky {
pub sky_color: [f32; 3],
pub fog_color: [f32; 3],
+29 -1
View File
@@ -8,6 +8,8 @@ use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
/// The name the options screen uses while the sort key waits for a key.
pub const SORT_KEY: &str = "horizon.sort";
/// The 3D skin layers line, here and in the `options.txt` that held it first.
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
@@ -157,6 +159,10 @@ pub struct Extras {
pub cosmetics_mine: bool,
pub cosmetics_others: horizon_cosmetics::prefs::Others,
pub cosmetics_auras: 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`).
pub three_d_skin_layers: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -206,6 +212,7 @@ impl Default for Extras {
cosmetics_mine: true,
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
cosmetics_auras: false,
three_d_skin_layers: false,
}
}
}
@@ -307,6 +314,7 @@ impl Extras {
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
}
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
_ => {}
}
}
@@ -314,7 +322,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n",
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
@@ -365,8 +373,28 @@ impl Extras {
bool_text(self.cosmetics_mine),
self.cosmetics_others.name(),
bool_text(self.cosmetics_auras),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
)
}
/// Adopts the settings `options.txt` held before they moved here, while
/// this file (`own`) has no line of its own: 3D skin layers was an
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
/// know, so it is read there once, the first start after the update.
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
fn line(text: &str) -> Option<&str> {
text.lines().find_map(|l| {
l.split_once(':')
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
.map(|(_, value)| value)
})
}
if line(own).is_none()
&& let Some(value) = line(options_txt)
{
self.three_d_skin_layers = value == "true";
}
}
/// Binds the extra key `name` (`SORT_KEY`); false for
/// another name (a vanilla mapping).
pub fn set_key(&mut self, name: &str, value: String) -> bool {
+1 -1
View File
@@ -21,7 +21,7 @@ pub enum FogType {
}
/// The fog uniform (`FogData`).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FogData {
pub color: [f32; 4],
pub environmental_start: f32,
+146 -41
View File
@@ -104,8 +104,11 @@ struct FontSet {
providers: Vec<Provider>,
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use).
by_width: Option<HashMap<u32, Vec<char>>>,
/// `SelectedGlyphs.nonFishy` of the characters whose first glyph has a fishy advance (the
/// others use `glyphs`): what obfuscated text draws.
non_fishy: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use), by `ceil(advance)`.
by_width: Option<HashMap<i32, Vec<char>>>,
}
/// The atlas region glyphs are rasterized into, filled in rows.
struct Region {
@@ -258,10 +261,11 @@ fn used_width(pixels: &[u8], stride: u32, (cx, cy, cw, ch): (u32, u32, u32, u32)
})
.map_or(0, |x| x + 1)
}
/// `FontSet.hasFishyAdvance`: a provider glyph whose advance is under 0 or
/// over 32 is not used. `FontSet.computeGlyphInfo` moves on to the next
/// provider that has the character, so a pack's absurd advance cannot widen
/// a line; Horizon keeps one advance per glyph and tests it here.
/// `FontSet.hasFishyAdvance`: an advance under 0 or over 32. Such a glyph is still the one drawn
/// (`SelectedGlyphs.any` is the first provider's); only `SelectedGlyphs.nonFishy`, what obfuscated
/// text draws, moves on to the next provider that has the character. Packs rely on it: a `space`
/// provider's kerning runs from -512 to 512, and a tall bitmap's glyphs pass 32. Horizon keeps one
/// advance per glyph and tests it here.
fn fishy_advance(advance: f32) -> bool {
!(0.0..=32.0).contains(&advance)
}
@@ -674,6 +678,7 @@ impl Font {
FontSet {
providers,
glyphs: Default::default(),
non_fishy: Default::default(),
by_width: None,
}
}
@@ -682,6 +687,7 @@ impl Font {
let mut cache = self.cache.borrow_mut();
for (_, set) in &mut cache.sets {
set.glyphs.clear();
set.non_fishy.clear();
set.by_width = None;
}
cache.region.cursor = (0, 0);
@@ -735,7 +741,7 @@ impl Font {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c);
let found = self.resolve(set, region, c, false);
// No room: the missing glyph for now, not remembered, so the
// character is tried again once `begin_frame` cleared the region.
let overflowed = std::mem::replace(&mut region.full, was_full);
@@ -747,13 +753,46 @@ impl Font {
set.glyphs.insert(c, glyph);
glyph
}
fn resolve(&self, set: &mut FontSet, region: &mut Region, c: char) -> Option<Glyph> {
/// `FontSet.getRandomGlyph`'s `SelectedGlyphs.nonFishy`: the glyph of the first provider whose
/// advance is not fishy, else the missing glyph. A character whose first glyph is fine has
/// the same one for both.
fn glyph_non_fishy(&self, id: &str, c: char) -> Glyph {
let any = self.glyph_of(id, c);
if !fishy_advance(any.advance) {
return any;
}
let mut cache = self.cache.borrow_mut();
let Cache { sets, region, .. } = &mut *cache;
let Some((_, set)) = sets.iter_mut().find(|(k, _)| k == id) else {
return self.missing;
};
if let Some(g) = set.non_fishy.get(&c) {
return *g;
}
let was_full = std::mem::replace(&mut region.full, false);
let found = self.resolve(set, region, c, true);
let overflowed = std::mem::replace(&mut region.full, was_full);
region.full |= overflowed;
if overflowed {
return self.missing;
}
let glyph = found.unwrap_or(self.missing);
set.non_fishy.insert(c, glyph);
glyph
}
fn resolve(
&self,
set: &mut FontSet,
region: &mut Region,
c: char,
non_fishy: bool,
) -> Option<Glyph> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
&& !(non_fishy && fishy_advance(*a))
{
return Some(Glyph {
rect: None,
@@ -768,10 +807,12 @@ impl Font {
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
Some(BitmapGlyph::Atlas(g)) if !(non_fishy && fishy_advance(g.advance)) => {
return Some(*g);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
Some(BitmapGlyph::Sheet(cell))
if !(non_fishy && fishy_advance(cell.glyph.advance)) =>
{
let (stride, pixels) = (&cell.sheet.0, &cell.sheet.1);
let mut rgba = Vec::with_capacity((cell.w * cell.h * 4) as usize);
for y in cell.y..cell.y + cell.h {
@@ -791,14 +832,14 @@ impl Font {
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c)).copied()
&& !fishy_advance(hex_advance(&h))
&& !(non_fishy && fishy_advance(hex_advance(&h)))
{
return self.rasterize_hex(region, h);
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
{
let (glyph, bitmap) = ttf.glyph(*id);
return match bitmap {
@@ -851,7 +892,7 @@ impl Font {
pub fn random_glyph(&self, style: &Style, advance: f32) -> Glyph {
use horizon_core::random::RandomSource;
let id = style.font.as_deref().unwrap_or(DEFAULT);
let width = advance.ceil() as u32;
let width = advance.ceil() as i32;
let pick = {
let mut cache = self.cache.borrow_mut();
let Cache { sets, random, .. } = &mut *cache;
@@ -869,9 +910,9 @@ impl Font {
chars.get(i as usize).copied()
})
};
pick.map_or(self.missing, |c| self.glyph_of(id, c))
pick.map_or(self.missing, |c| self.glyph_non_fishy(id, c))
}
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<u32, Vec<char>> {
fn glyphs_by_width(&self, set: &mut FontSet) -> HashMap<i32, Vec<char>> {
let (uniform, japanese) = (self.uniform, self.japanese);
let mut chars = std::collections::BTreeSet::new();
let mut in_atlas = false;
@@ -893,33 +934,31 @@ impl Font {
// Nothing in the atlas: the glyphs below U+0250, rasterized as used.
chars.extend((0..0x250u32).filter_map(char::from_u32));
}
let mut out: HashMap<u32, Vec<char>> = HashMap::new();
let mut out: HashMap<i32, Vec<char>> = HashMap::new();
for c in chars {
if let Some(advance) = self.provider_advance(set, c) {
out.entry(advance.ceil() as u32).or_default().push(c);
out.entry(advance.ceil() as i32).or_default().push(c);
}
}
out
}
/// A character's advance in `set` (its first provider that has it),
/// without rasterizing it.
/// A character's advance in `set` (its first provider that has it, fishy or not, as the
/// by-width table of `FontSet.reload` takes it), without rasterizing it.
fn provider_advance(&self, set: &mut FontSet, c: char) -> Option<f32> {
let (uniform, japanese) = (self.uniform, self.japanese);
for provider in &mut set.providers {
match provider {
Provider::Space(advances) => {
if let Some(a) = advances.get(&c)
&& !fishy_advance(*a)
{
if let Some(a) = advances.get(&c) {
return Some(*a);
}
}
Provider::Bitmap(filter, glyphs) if filter.passes(uniform, japanese) => {
match glyphs.get(&c) {
Some(BitmapGlyph::Atlas(g)) if !fishy_advance(g.advance) => {
Some(BitmapGlyph::Atlas(g)) => {
return Some(g.advance);
}
Some(BitmapGlyph::Sheet(cell)) if !fishy_advance(cell.glyph.advance) => {
Some(BitmapGlyph::Sheet(cell)) => {
return Some(cell.glyph.advance);
}
_ => {}
@@ -927,16 +966,12 @@ impl Font {
}
Provider::Unihex(filter, def, glyphs) if filter.passes(uniform, japanese) => {
let glyphs = glyphs.get_or_insert_with(|| load_hex(&self.root, def));
if let Some(h) = glyphs.get(&u32::from(c))
&& !fishy_advance(hex_advance(h))
{
if let Some(h) = glyphs.get(&u32::from(c)) {
return Some(hex_advance(h));
}
}
Provider::TrueType(filter, ttf) if filter.passes(uniform, japanese) => {
if let Some(id) = ttf.ids.get(&c)
&& !fishy_advance(ttf.advance(*id))
{
if let Some(id) = ttf.ids.get(&c) {
return Some(ttf.advance(*id));
}
}
@@ -1406,12 +1441,13 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// `FontSet.computeGlyphInfo`: a provider glyph with an advance outside
/// 0..32 is skipped, so the next provider that has the character is used.
/// `FontSet.computeGlyphInfo`: `SelectedGlyphs.any` is the first provider's glyph, fishy
/// advance or not, and is what is drawn and measured; `nonFishy`, what obfuscated text draws,
/// is the first provider whose advance is within 0..32, else the missing glyph.
#[test]
fn a_fishy_advance_falls_through_to_the_next_provider() {
// 64 opaque columns: the first provider's 'a' would advance 65, past
// the 32 vanilla allows, so the narrow provider's 'a' is the one used.
fn a_fishy_glyph_is_drawn_and_only_obfuscated_text_falls_through() {
// 64 opaque columns: the first provider's 'a' advances 65, past the 32 vanilla
// calls fishy, and the narrow provider's 'a' advances 3.
let wide = png(64, 8, |_, _| true);
let narrow = png(16, 8, |x, _| x < 2);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/wide.png","ascent":7,"chars":["a"]},{"type":"bitmap","file":"minecraft:font/narrow.png","ascent":7,"chars":["a"]}]}"#;
@@ -1425,11 +1461,80 @@ mod tests {
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(font.glyph('a').advance, 3.0, "the narrow provider wins");
assert_eq!(font.advance('a', &Style::default()), 3.0);
// Obfuscated text picks a glyph by its own advance: nothing is 65 wide.
let obfuscated = font.random_glyph(&Style::default(), 65.0);
assert_eq!(obfuscated.advance, 6.0, "the missing glyph");
let drawn = font.glyph('a');
assert_eq!(drawn.advance, 65.0, "the first provider's glyph is drawn");
assert!(drawn.rect.is_some());
assert_eq!(font.advance('a', &Style::default()), 65.0);
// The by-width table files 'a' under its first glyph's 65; the glyph it then draws is
// the narrow provider's (`nonFishy`).
assert_eq!(font.random_glyph(&Style::default(), 65.0).advance, 3.0);
// Nothing else is 3 wide in the table, and the missing glyph is 6.
assert_eq!(font.random_glyph(&Style::default(), 3.0).advance, 6.0);
}
/// A `space` provider's shift glyphs (kerning in server packs: -512 to 512) are drawn and
/// measured as they are, and `nonFishy` has nothing else to draw for them.
#[test]
fn space_glyphs_with_any_advance_are_kept() {
let shifts: Vec<i32> = (0..10)
.map(|i| -(1 << i))
.chain((0..10).map(|i| 1 << i))
.collect();
let advances: Vec<String> = shifts
.iter()
.enumerate()
.map(|(i, a)| format!("\"\\ue{:03x}\": {a}", i))
.collect();
let def = format!(
r#"{{"providers":[{{"type":"space","advances":{{{}}}}}]}}"#,
advances.join(",")
);
let root = root(
"space-shifts",
&[("assets/minecraft/font/default.json", def.as_bytes())],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
for (i, a) in shifts.iter().enumerate() {
let c = char::from_u32(0xe000 + i as u32).unwrap();
assert_eq!(font.glyph(c).advance, *a as f32, "{c:?}");
assert_eq!(font.advance(c, &Style::default()), *a as f32);
}
// Obfuscated text asks for a glyph of a given width: a fishy space has none to give.
let c = char::from_u32(0xe000 + 19).unwrap();
assert_eq!(font.glyph(c).advance, 512.0);
assert_eq!(
font.glyph_non_fishy(DEFAULT, c).advance,
6.0,
"the missing glyph"
);
assert_eq!(font.random_glyph(&Style::default(), 512.0).advance, 6.0);
}
/// Nexo's GUI glyphs: a bitmap `height` of 287 with no advance, so every glyph wider than 31
/// pixels has a fishy advance, and is still drawn.
#[test]
fn a_wide_bitmap_glyph_is_drawn() {
let sheet = png(40, 287, |_, _| true);
let def = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/gui.png","height":287,"ascent":65,"chars":["a"]}]}"#;
let root = root(
"wide-bitmap",
&[
("assets/minecraft/font/default.json", def),
("assets/minecraft/textures/font/gui.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let g = font.glyph('a');
assert_eq!(g.advance, 41.0, "the glyph's 40 columns and one");
assert!(g.rect.is_some(), "drawn, not the missing glyph");
assert!(fishy_advance(g.advance));
assert_eq!(
font.glyph_non_fishy(DEFAULT, 'a').advance,
6.0,
"excluded from what obfuscated text draws"
);
}
/// StringDecomposer.iterateFormatted: a `§` that ends a part stops that
File diff suppressed because it is too large. Load diff
@@ -877,6 +877,8 @@ mod tests {
start,
translucent_at,
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1014,7 +1016,7 @@ mod tests {
label: None,
entries: &[],
});
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _| {
let pipeline = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str, _, _, _| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -1071,6 +1073,8 @@ mod tests {
start: 0,
translucent_at: (0, at),
solid: 64,
solid_slots: 64 * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(64),
translucent: 128,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -1295,6 +1299,8 @@ mod tests {
start: 0,
translucent_at: if indices { (7, 7) } else { at },
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::super::translucency::SortState::new(Vec::new()),
@@ -0,0 +1,575 @@
//! `horizon-ctl render-capture`: what the renderer needs to draw the current frame again
//! without the game, for `examples/scene_replay.rs` (docs/graphics.md, "Render capture and
//! replay").
//!
//! The renderer keeps no CPU copy of the meshes it uploads, so while capturing is on
//! (`Renderer::keep_capture_copies`) it keeps one of every section and far tile mesh as it is
//! handed over, at the mesher-to-renderer seam. A capture writes those of the sections and
//! tiles it holds, the frame's camera and inputs, and the settings that change GPU work. Only
//! the build that wrote a capture reads it.
//!
//! Files: `scene.json` (`Scene`), `sections.json` and `tiles.json` (one `MeshRecord` per mesh,
//! in order), `sections.bin` and `tiles.bin` (each mesh's solid then translucent
//! `TerrainVertex`es), `celestial.bin` (the sun and moon `Vertex`es).
use super::{Frame, Renderer, SectionKey};
use crate::{
Result,
environment::Sky,
fast_hash::{FxHashMap, FxHashSet},
fog::FogData,
graphics::GraphicsApi,
hud::HudBatch,
lod::TileKey,
mesher::{SectionMesh, Sides, TerrainVertex, Vertex},
};
use glam::{DVec3, Mat4, Vec3};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io::{BufReader, BufWriter, Read, Write},
path::{Path, PathBuf},
};
/// CPU copies of the meshes uploaded since capturing was turned on.
#[derive(Default)]
pub(super) struct Copies {
sections: FxHashMap<SectionKey, SectionMesh>,
tiles: FxHashMap<TileKey, SectionMesh>,
}
impl Copies {
pub(super) fn section(&mut self, key: SectionKey, mesh: &SectionMesh) {
self.sections.insert(key, copy(mesh));
}
/// Columns unloaded (`Renderer::remove_columns`).
pub(super) fn remove_columns(&mut self, columns: &[(i32, i32)]) {
if columns.is_empty() {
return;
}
let gone: FxHashSet<(i32, i32)> = columns.iter().copied().collect();
self.sections
.retain(|&(x, _, z), _| !gone.contains(&(x, z)));
}
pub(super) fn tile(&mut self, key: TileKey, mesh: &SectionMesh) {
self.tiles.insert(key, copy(mesh));
}
pub(super) fn forget_tile(&mut self, key: TileKey) {
self.tiles.remove(&key);
}
pub(super) fn clear_tiles(&mut self) {
self.tiles.clear();
}
}
fn copy(mesh: &SectionMesh) -> SectionMesh {
SectionMesh {
solid: mesh.solid.clone(),
sides: mesh.sides,
translucent: mesh.translucent.clone(),
packed: mesh.packed.clone(),
solid_tints: Vec::new(),
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh.bounds,
keep: mesh.keep,
}
}
/// A capture asked for: where to write it, and the game's settings the renderer does not hold.
pub struct Request {
pub dir: PathBuf,
pub settings: Settings,
}
/// The game's settings that change GPU work, as the game applied them to the renderer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Settings {
/// `GraphicsApi`'s variant name.
pub graphics_api: String,
pub translucent_batching: bool,
/// `textureFiltering` and `maxAnisotropyBit` (`Renderer::set_texture_filtering`).
pub texture_filtering: i32,
pub anisotropy_bit: i32,
pub mipmap_levels: u32,
/// `Renderer::set_world_post`'s arguments.
pub resolution_scale: f32,
pub fxaa: bool,
pub taa: bool,
pub upscale: f32,
/// Chunks.
pub render_distance: i32,
pub lod_distance: u32,
/// The game's prepared resources, where the replay loads the block sprites from.
pub resources: PathBuf,
/// The block sprites the meshes were made against.
pub atlas: AtlasCheck,
}
impl Settings {
pub fn graphics_api(&self) -> GraphicsApi {
GraphicsApi::ALL
.into_iter()
.find(|api| format!("{api:?}") == self.graphics_api)
.unwrap_or_default()
}
}
/// Which block sprite layers the meshes' vertices name: the replay's must be the same.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AtlasCheck {
pub sprite_size: u32,
pub texture_layers: u32,
/// FNV-1a of every sprite's name and layer.
pub layers: u64,
}
impl AtlasCheck {
pub fn of(render: &crate::assets::RenderAssets) -> Self {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for (name, layer) in &render.sprite_layers {
for byte in name.bytes().chain(layer.to_le_bytes()) {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3);
}
}
Self {
sprite_size: render.sprite_size,
texture_layers: render.texture_layers,
layers: hash,
}
}
}
/// The renderer's own state that changes GPU work.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RendererState {
pub adapter: String,
pub backend: String,
/// The frame's size in pixels.
pub size: [u32; 2],
/// The column layout: lowest block y and sections.
pub min_y: i32,
pub sections: usize,
pub flat_draws: bool,
pub section_cull: bool,
pub section_sides: bool,
pub gpu_culling: bool,
pub gamma: f32,
pub fullbright: bool,
pub improved_transparency: bool,
pub animated_textures: bool,
/// A shader pack was running; the replay draws without it.
pub shader_pack: bool,
}
/// The cloud inputs of the frame (`clouds::Input`), with the clouds texture.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Clouds {
pub texture: (u32, u32, Vec<bool>),
pub uv: [f32; 2],
pub camera: [f64; 3],
pub time: i64,
pub partial_tick: f64,
pub range: i32,
pub fast: bool,
pub color: [f32; 4],
}
/// The frame's camera and the inputs of its globals, sky, fog and lightmap.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FrameInputs {
pub camera: [f64; 3],
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
pub eye: [f64; 3],
pub eye_rotation: [f32; 2],
pub first_person: bool,
pub bob: [f32; 16],
pub spin: [f32; 16],
pub sky: Sky,
pub distance: f32,
pub detail_distance: f32,
pub depth_far: f32,
pub underwater: bool,
pub water_plane: Option<f32>,
pub cave_culling: bool,
pub sky_blocked: bool,
pub skybox: bool,
pub end_sky: bool,
pub end_sky_sprite: Option<[f32; 4]>,
pub environment_fog: Option<(f32, f32)>,
pub water_fog: [f32; 3],
pub fog: Option<FogData>,
pub dark_disc: bool,
pub nether_lighting: bool,
pub glint_alpha: f32,
pub glint_ticks: f32,
pub hand_fov_degrees: f32,
pub stars: Option<([f32; 16], f32)>,
pub clouds: Option<Clouds>,
pub game_time: f32,
pub shader_game_time: f32,
pub end_portal_sprites: [[f32; 4]; 2],
pub crumbling_sprite: [f32; 4],
pub ticks: u64,
pub darkness: (f32, f32),
pub night_vision: f32,
pub block_flicker: f32,
pub boss_darken: f32,
}
/// `scene.json`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Scene {
pub settings: Settings,
pub renderer: RendererState,
pub frame: FrameInputs,
pub sections: usize,
pub tiles: usize,
/// The chunk columns far tiles leave out for full-detail sections.
pub lod_cutout: Vec<(i32, i32)>,
/// `celestial.bin`.
#[serde(skip)]
pub celestial: Vec<Vertex>,
}
/// One mesh of `sections.json` or `tiles.json`; its vertices follow the previous mesh's in
/// the `.bin` file. `key` is (x, section y, z) for a section, (x, z, level) for a tile.
#[derive(Serialize, Deserialize)]
struct MeshRecord {
key: [i32; 3],
solid: usize,
translucent: usize,
sides: Sides,
visibility: u64,
block_entities: Vec<([i32; 3], u32)>,
lights: Vec<([i32; 3], u32)>,
sprite_layers: Vec<u16>,
bounds: Option<[[f32; 3]; 2]>,
keep: bool,
}
/// Writes `name.json` and `name.bin`: (meshes, vertex bytes).
fn write_meshes<'m>(
dir: &Path,
name: &str,
meshes: impl Iterator<Item = ([i32; 3], &'m SectionMesh)>,
) -> Result<(usize, u64)> {
let mut blob = BufWriter::new(File::create(dir.join(format!("{name}.bin")))?);
let mut records = Vec::new();
let mut bytes = 0u64;
for (key, mesh) in meshes {
for part in [&mesh.solid, &mesh.translucent] {
let raw: &[u8] = bytemuck::cast_slice(part.as_slice());
blob.write_all(raw)?;
bytes += raw.len() as u64;
}
records.push(MeshRecord {
key,
solid: mesh.solid.len(),
translucent: mesh.translucent.len(),
sides: mesh.sides,
visibility: mesh.visibility,
block_entities: mesh.block_entities.clone(),
lights: mesh.lights.clone(),
sprite_layers: mesh.sprite_layers.clone(),
bounds: mesh
.bounds
.map(|[min, max]| [min.to_array(), max.to_array()]),
keep: mesh.keep,
});
}
blob.flush()?;
let mut list = BufWriter::new(File::create(dir.join(format!("{name}.json")))?);
serde_json::to_writer(&mut list, &records)?;
list.flush()?;
Ok((records.len(), bytes))
}
/// Reads `name.json` and `name.bin`, handing each mesh to `each` as it is read.
pub fn read_meshes(
dir: &Path,
name: &str,
mut each: impl FnMut([i32; 3], SectionMesh),
) -> Result<()> {
let records: Vec<MeshRecord> = serde_json::from_reader(BufReader::new(File::open(
dir.join(format!("{name}.json")),
)?))?;
let mut blob = BufReader::new(File::open(dir.join(format!("{name}.bin")))?);
for record in records {
let mut solid = vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.solid];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
solid.as_mut_slice(),
))?;
let mut translucent =
vec![<TerrainVertex as bytemuck::Zeroable>::zeroed(); record.translucent];
blob.read_exact(bytemuck::cast_slice_mut::<TerrainVertex, u8>(
translucent.as_mut_slice(),
))?;
each(
record.key,
SectionMesh {
solid,
sides: record.sides,
translucent,
visibility: record.visibility,
block_entities: record.block_entities,
lights: record.lights,
sprite_layers: record.sprite_layers,
bounds: record
.bounds
.map(|[min, max]| [Vec3::from_array(min), Vec3::from_array(max)]),
keep: record.keep,
..Default::default()
},
);
}
Ok(())
}
impl Renderer {
/// Keeps CPU copies of the meshes uploaded from now on, for `render-capture`, or drops
/// them. They start empty: the caller meshes everything again for a complete capture.
pub fn keep_capture_copies(&mut self, on: bool) {
if on != self.capture_copies.is_some() {
self.capture_copies = on.then(Copies::default);
}
}
pub fn keeps_capture_copies(&self) -> bool {
self.capture_copies.is_some()
}
/// Writes the scene `frame` draws to `request.dir` (`render` calls it for `capture`).
pub(super) fn write_capture(
&self,
request: &Request,
frame: &Frame,
) -> Result<serde_json::Value> {
let copies = self
.capture_copies
.as_ref()
.ok_or("no mesh copies kept: render-capture on first")?;
let dir = &request.dir;
fs::create_dir_all(dir)?;
let (sections, section_bytes) = write_meshes(
dir,
"sections",
copies
.sections
.iter()
.map(|(&(x, y, z), mesh)| ([x, y, z], mesh)),
)?;
let standing = self.standing_lods();
let (tiles, tile_bytes) = write_meshes(
dir,
"tiles",
copies
.tiles
.iter()
.filter(|(key, _)| standing.contains(*key))
.map(|(key, mesh)| ([key.x, key.z, i32::from(key.level)], mesh)),
)?;
fs::write(
dir.join("celestial.bin"),
bytemuck::cast_slice::<Vertex, u8>(frame.celestial),
)?;
let scene = Scene {
settings: request.settings.clone(),
renderer: RendererState {
adapter: self.info.name.clone(),
backend: format!("{:?}", self.info.backend),
size: [self.config.width, self.config.height],
min_y: self.layout.min_y(),
sections: self.layout.sections(),
flat_draws: self.flat_draws(),
section_cull: self.section_cull(),
section_sides: self.section_sides(),
gpu_culling: self.gpu_culling(),
gamma: self.gamma,
fullbright: self.fullbright,
improved_transparency: self.improved_transparency,
animated_textures: self.animated_textures,
shader_pack: self.pack_post.is_some() || self.native.is_some(),
},
frame: FrameInputs {
camera: frame.camera.to_array(),
yaw: frame.yaw,
pitch: frame.pitch,
fov_degrees: frame.fov_degrees,
eye: frame.eye.to_array(),
eye_rotation: frame.eye_rotation,
first_person: frame.first_person,
bob: frame.bob.to_cols_array(),
spin: frame.spin.to_cols_array(),
sky: frame.sky,
distance: frame.distance,
detail_distance: frame.detail_distance,
depth_far: frame.depth_far,
underwater: frame.underwater,
water_plane: frame.water_plane,
cave_culling: frame.cave_culling,
sky_blocked: frame.sky_blocked,
skybox: frame.skybox,
end_sky: frame.end_sky,
end_sky_sprite: frame.end_sky_sprite,
environment_fog: frame.environment_fog,
water_fog: frame.water_fog,
fog: frame.fog,
dark_disc: frame.dark_disc,
nether_lighting: frame.nether_lighting,
glint_alpha: frame.glint_alpha,
glint_ticks: frame.glint_ticks,
hand_fov_degrees: frame.hand_fov_degrees,
stars: frame
.stars
.map(|(rotation, brightness)| (rotation.to_cols_array(), brightness)),
clouds: frame.clouds.as_ref().map(|c| Clouds {
texture: c.texture.clone(),
uv: c.uv,
camera: c.camera.to_array(),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
game_time: frame.game_time,
shader_game_time: frame.shader_game_time,
end_portal_sprites: frame.end_portal_sprites,
crumbling_sprite: frame.crumbling_sprite,
ticks: frame.ticks,
darkness: frame.darkness,
night_vision: frame.night_vision,
block_flicker: frame.block_flicker,
boss_darken: frame.boss_darken,
},
sections,
tiles,
lod_cutout: self.lod_cutout.iter().copied().collect(),
celestial: Vec::new(),
};
let mut out = BufWriter::new(File::create(dir.join("scene.json"))?);
serde_json::to_writer_pretty(&mut out, &scene)?;
out.flush()?;
Ok(serde_json::json!({
"ok": true,
"dir": dir.display().to_string(),
"sections": sections,
"tiles": tiles,
"vertex_bytes": section_bytes + tile_bytes,
"shader_pack_left_out": scene.renderer.shader_pack,
}))
}
}
impl Scene {
/// Reads `scene.json` and `celestial.bin` (the meshes: `read_meshes`).
pub fn read(dir: &Path) -> Result<Self> {
let mut scene: Scene =
serde_json::from_reader(BufReader::new(File::open(dir.join("scene.json"))?))?;
let bytes = fs::read(dir.join("celestial.bin"))?;
let size = std::mem::size_of::<Vertex>();
let mut celestial = vec![<Vertex as bytemuck::Zeroable>::zeroed(); bytes.len() / size];
let len = celestial.len() * size;
bytemuck::cast_slice_mut::<Vertex, u8>(celestial.as_mut_slice())
.copy_from_slice(&bytes[..len]);
scene.celestial = celestial;
Ok(scene)
}
/// The captured frame: its camera, sky, fog, clouds, sun, moon and stars, without
/// entities, particles, weather, the hand, the outline or the HUD.
pub fn frame<'a>(&'a self, hud: &'a HudBatch) -> Frame<'a> {
let f = &self.frame;
Frame {
weather: &[],
hand_items: &[],
weather_time: crate::weather::Time::new(0, 0.0),
camera: DVec3::from_array(f.camera),
yaw: f.yaw,
pitch: f.pitch,
fov_degrees: f.fov_degrees,
eye: DVec3::from_array(f.eye),
eye_rotation: f.eye_rotation,
first_person: f.first_person,
bob: Mat4::from_cols_array(&f.bob),
spin: Mat4::from_cols_array(&f.spin),
sky: f.sky,
shadow_scene_revision: (0, 0),
shadow_cache: false,
distance: f.distance,
detail_distance: f.detail_distance,
depth_far: f.depth_far,
underwater: f.underwater,
water_plane: f.water_plane,
cave_culling: f.cave_culling,
title: false,
sky_blocked: f.sky_blocked,
skybox: f.skybox,
end_sky: f.end_sky,
end_sky_sprite: f.end_sky_sprite,
environment_fog: f.environment_fog,
water_fog: f.water_fog,
fog: f.fog,
dark_disc: f.dark_disc,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: f.glint_alpha,
glint_ticks: f.glint_ticks,
nether_lighting: f.nether_lighting,
entities: &[],
static_block_entities: &[],
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
emissive_layers: &[],
clouds: f.clouds.as_ref().map(|c| crate::clouds::Input {
texture: &c.texture,
uv: c.uv,
camera: DVec3::from_array(c.camera),
time: c.time,
partial_tick: c.partial_tick,
range: c.range,
fast: c.fast,
color: c.color,
}),
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &self.celestial,
stars: f
.stars
.map(|(rotation, brightness)| (Mat4::from_cols_array(&rotation), brightness)),
hand: &[],
hand_fov_degrees: f.hand_fov_degrees,
screen: &[],
pack: Default::default(),
border: None,
game_time: f.game_time,
shader_game_time: f.shader_game_time,
end_portal_sprites: f.end_portal_sprites,
crumbling_sprite: f.crumbling_sprite,
ticks: f.ticks,
darkness: f.darkness,
night_vision: f.night_vision,
block_flicker: f.block_flicker,
boss_darken: f.boss_darken,
chunk_fade: 0.0,
post_effects: crate::post_chain::Request::none(),
menu_blur: None,
}
}
}
+80 -20
View File
@@ -19,6 +19,10 @@
//! the results only sorts sections between the phases: a stale result draws
//! a hidden section in phase 1, or tests a section that is in sight.
//!
//! Far terrain tiles are tested the same way, with their meshes' own
//! bounds for a box (grown by a block too, which keeps their faces off the
//! box's), and resident slots of their own (`TILE`).
//!
//! Every solid draw is an entry of one of the two lists, so the CPU records
//! as many draws as without culling: drivers that unroll an indirect
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
@@ -39,6 +43,11 @@ use std::sync::{
};
const WORDS: u64 = 5 * 4;
/// A tested section's box, around its origin: its cell grown by a block.
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
/// count their slots apart from the sections' resident ones.
pub const TILE: u32 = 1 << 31;
/// Phase 1's sections are each tested every `RETEST` frames.
const RETEST: u32 = 8;
/// Culling runs while the last result read back hid at least this share of
@@ -63,6 +72,17 @@ const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// One test, as `cull.wgsl` reads it: visible index and phase 2 draws
/// (`place`), then its box's corners around the origin.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Test {
data: [u32; 4],
low: [f32; 4],
high: [f32; 4],
}
const TEST_BYTES: u64 = std::mem::size_of::<Test>() as u64;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
@@ -131,8 +151,10 @@ pub struct Cull {
phase2_capacity: u64,
tests: wgpu::Buffer,
tests_capacity: u64,
/// This frame's tests: visible index, first phase 2 draw, phase 2 draws.
test_data: Vec<[u32; 4]>,
/// This frame's tests: visible index, then its phase 2 draws in each
/// solid pass (`place`): the first of the first pass's, both counts (the
/// second's in the high half), the first of the second pass's; and its box.
test_data: Vec<Test>,
test_slots: Vec<u32>,
test_indices: Vec<u32>,
total: u64,
@@ -142,8 +164,10 @@ pub struct Cull {
readbacks: Vec<Readback>,
next: usize,
current: Option<usize>,
/// Per resident slot: whether the last result read back saw it.
/// Per resident slot: whether the last result read back saw it; and per
/// far tile's slot (`TILE`).
seen: Vec<bool>,
seen_tiles: Vec<bool>,
stamp: u32,
/// Of the boxes the last test read back drew, how many it did not see
/// and how many it drew, and the share of solid indices hidden.
@@ -325,7 +349,7 @@ impl Cull {
),
phase2: buffer(device, "Section draws, phase 2", WORDS, PHASE2_USAGE),
phase2_capacity: 1,
tests: buffer(device, "Section tests", 16, TESTS_USAGE),
tests: buffer(device, "Section tests", TEST_BYTES, TESTS_USAGE),
tests_capacity: 1,
test_data: Vec::new(),
test_slots: Vec::new(),
@@ -347,6 +371,7 @@ impl Cull {
next: 0,
current: None,
seen: Vec::new(),
seen_tiles: Vec::new(),
stamp: 0,
hidden: ([0; 2], 1.0),
always: false,
@@ -378,11 +403,15 @@ impl Cull {
let share = indices as f32 / r.total.max(1) as f32;
self.hidden = ([hidden, results.len()], share);
for (&slot, &result) in r.slots.iter().zip(&results) {
let slot = slot as usize;
if slot >= self.seen.len() {
self.seen.resize(slot + 1, false);
let (seen, slot) = if slot & TILE != 0 {
(&mut self.seen_tiles, (slot & !TILE) as usize)
} else {
(&mut self.seen, slot as usize)
};
if slot >= seen.len() {
seen.resize(slot + 1, false);
}
self.seen[slot] = result == r.stamp;
seen[slot] = result == r.stamp;
}
}
r.buffer.unmap();
@@ -397,30 +426,61 @@ impl Cull {
!self.probe || self.stamp.is_multiple_of(PROBE)
}
/// Whether phase 1 draws the section in resident `slot`.
/// Whether phase 1 draws the section in resident `slot` (or the far tile
/// in its `TILE` slot).
pub fn first(&self, slot: u32) -> bool {
if slot & TILE != 0 {
return self
.seen_tiles
.get((slot & !TILE) as usize)
.copied()
.unwrap_or(false);
}
self.seen.get(slot as usize).copied().unwrap_or(false)
}
/// A section phase 1 draws, `index` in the visible list, with `indices`
/// solid indices: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32) {
/// solid indices and the box `bounds` around its origin: tested in turn.
pub fn drawn(&mut self, index: usize, slot: u32, indices: u32, bounds: [[f32; 3]; 2]) {
self.total += u64::from(indices);
if indices > 0 && (self.probe || slot.wrapping_add(self.stamp).is_multiple_of(RETEST)) {
self.push([index as u32, 0, 0, 0], slot, indices);
self.push(index, bounds, slot, indices);
}
}
/// A section left to phase 2: its `draws` from phase 2's `first`.
pub fn deferred(&mut self, index: usize, slot: u32, first: usize, draws: usize, indices: u32) {
/// A section left to phase 2, with `draws` draws there: its test, whose
/// draws `place` gives once phase 2's list is in its final order.
pub fn deferred(
&mut self,
index: usize,
slot: u32,
draws: usize,
indices: u32,
bounds: [[f32; 3]; 2],
) -> Option<u32> {
self.total += u64::from(indices);
if draws > 0 {
self.push([index as u32, first as u32, draws as u32, 0], slot, indices);
}
(draws > 0).then(|| {
self.push(index, bounds, slot, indices);
self.test_data.len() as u32 - 1
})
}
fn push(&mut self, test: [u32; 4], slot: u32, indices: u32) {
self.test_data.push(test);
/// `test`'s draws in solid pass `pass` (0 or 1): `draws` from phase 2's
/// `first`. Each pass draws its own part of the section's quads.
pub fn place(&mut self, test: u32, pass: u32, first: u32, draws: u32) {
debug_assert!(pass < 2 && draws < 1 << 16);
let t = &mut self.test_data[test as usize].data;
t[1 + 2 * pass as usize] = first;
t[2] |= draws << (16 * pass);
}
fn push(&mut self, index: usize, bounds: [[f32; 3]; 2], slot: u32, indices: u32) {
let [low, high] = bounds.map(|[x, y, z]| [x, y, z, 0.0]);
self.test_data.push(Test {
data: [index as u32, 0, 0, 0],
low,
high,
});
self.test_slots.push(slot);
self.test_indices.push(indices);
}
@@ -454,7 +514,7 @@ impl Cull {
self.tests = buffer(
device,
"Section tests",
self.tests_capacity * 16,
self.tests_capacity * TEST_BYTES,
TESTS_USAGE,
);
self.results = buffer(
+36 -20
View File
@@ -11,10 +11,19 @@ struct Globals {
view_proj: mat4x4<f32>,
}
// One test per section: its visible index (which names its origin), its
// first draw in `phase2` and how many draws it has there (0: phase 1 drew
// it, and the test only tells whether it is still in sight).
@group(0) @binding(0) var<storage, read> tests: array<vec4<u32>>;
// One test per section or far tile: its visible index (which names its
// origin), then its draws in `phase2` for each solid pass (gfx/cull.rs's
// `place`): the first pass's first draw, both passes' draw counts (the
// second's in the high 16 bits), the second pass's first draw. No draws:
// phase 1 drew it, and the test only tells whether it is still in sight.
// Then its box's lowest and highest corner around the origin (a section's:
// -1 and 17, `SECTION_BOX`).
struct Test {
data: vec4<u32>,
low: vec4<f32>,
high: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> tests: array<Test>;
// Per test: the stamp of the frame whose test saw the box.
@group(0) @binding(1) var<storage, read_write> results: array<u32>;
// Phase 2's draws, 5 words each, with instance counts of 0 until seen.
@@ -36,23 +45,27 @@ struct Box {
@location(0) @interpolate(flat) test: u32,
}
fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f32> {
return origin + vec3(
select(-1.0, 17.0, (c & 1u) != 0u),
select(-1.0, 17.0, (c & 2u) != 0u),
select(-1.0, 17.0, (c & 4u) != 0u),
select(low.x, high.x, (c & 1u) != 0u),
select(low.y, high.y, (c & 2u) != 0u),
select(low.z, high.z, (c & 4u) != 0u),
);
}
// Each tested section's box, grown by a block (block models reach one block
// out of their cell), drawn after phase 1 against its depth with back faces
// culled: a front face fragment that passes the depth test is the box's
// nearest point on that pixel in front of phase 1's depth, so the section
// may show there. A box failing everywhere would have every fragment of its
// section fail the depth test against the final depth, which is only nearer.
// Each tested section's or far tile's box, grown by a block (block models
// reach one block out of their cell), drawn after phase 1 against its depth
// with back faces culled: a front face fragment that passes the depth test
// is the box's nearest point on that pixel in front of phase 1's depth, so
// the section may show there. A box failing everywhere would have every
// fragment of its section fail the depth test against the final depth,
// which is only nearer.
@vertex
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
let origin = origins[tests[t].x].xyz;
let test = tests[t];
let origin = origins[test.data.x].xyz;
let low = test.low.xyz;
let high = test.high.xyz;
var out: Box;
out.test = t;
// The box's screen rectangle, from the corners in front of the camera.
@@ -60,7 +73,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
var hi = vec2(-1e30);
var behind = false;
for (var c = 0u; c < 8u; c++) {
let p = globals.view_proj * vec4(corner(origin, c), 1.0);
let p = globals.view_proj * vec4(corner(origin, low, high, c), 1.0);
behind = behind || p.w <= 0.0;
let q = p.xy / max(p.w, 1e-6);
lo = min(lo, q);
@@ -71,7 +84,7 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
// clips its front faces away, and a box over a large part of the screen
// is nearly always in sight and costly to test: both count as seen,
// through a small triangle in the top left corner at the nearest depth.
let near = all(origin < vec3(2.0)) && all(origin > vec3(-18.0));
let near = all(origin < vec3(1.0) - low) && all(origin > vec3(-1.0) - high);
if near || (!behind && size.x * size.y > params.big * 4.0) {
var p = vec2(-1.0, 1.0);
if v == 1u {
@@ -82,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, CORNERS[v]), 1.0);
out.position = globals.view_proj * vec4(corner(origin, low, high, CORNERS[v]), 1.0);
return out;
}
@@ -91,10 +104,13 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
fn see(t: u32) {
if results[t] != params.stamp {
results[t] = params.stamp;
let test = tests[t];
for (var k = 0u; k < test.z; k++) {
let test = tests[t].data;
for (var k = 0u; k < (test.z & 0xffffu); k++) {
phase2[(test.y + k) * 5u + 1u] = 1u;
}
for (var k = 0u; k < (test.z >> 16u); k++) {
phase2[(test.w + k) * 5u + 1u] = 1u;
}
}
}
+158 -53
View File
@@ -42,8 +42,9 @@ struct Plan {
solid_calls: usize,
/// Where the shadow camera's solid and translucent calls begin.
shadow_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
pass_calls: [usize; 2],
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls,
/// and where its cutout pass begins in phase 2's.
pass_calls: [usize; 3],
total: u64,
}
const QUADS: u32 = u32::MAX;
@@ -53,10 +54,15 @@ const GROUP_LIMIT: u32 = 65535;
/// below it. A call names its key: its vertices come from that arena's page.
pub const TRANSLUCENT_KEYS: u32 = 1 << 10;
/// terrain.wgsl's `arena_page`: the page the solid draws read their vertices from.
pub const ARENA_BINDING: u32 = 19;
struct PageTable {
/// Section id of every block of the page.
blocks: wgpu::Buffer,
group: Option<wgpu::BindGroup>,
/// The section arena's page `group` binds at `ARENA_BINDING` (`bind_pages`).
arena: Option<wgpu::Buffer>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
@@ -81,8 +87,9 @@ pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
/// The solid draws in two passes over the same draws (terrain.wgsl's `opaque_quad`):
/// the quads with no cutout first, through a shader that never discards, then the rest.
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
@@ -128,8 +135,9 @@ pub struct Flat {
pub phase1_calls: usize,
pub solid_calls: usize,
/// Metal's two solid passes over phase 1: the opaque one draws
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
pub pass_calls: [usize; 2],
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`;
/// over phase 2, `calls[phase1_calls..pass_calls[2]]` and the rest.
pub pass_calls: [usize; 3],
/// Where a shader pack's shadow camera's solid and translucent calls
/// begin (`calls.len()` without them): the camera's translucent calls
/// end at the first.
@@ -137,6 +145,10 @@ pub struct Flat {
/// Frames with fewer sections in view keep the per-section draws.
pub min_sections: usize,
range_align: u64,
/// Phase 2's calls draw their page key as their first instance, as `draw_paged` does:
/// the visibility buffer's ID pass (`visbuffer`) reads it. The other flat pipelines
/// have no instance-rate inputs.
pub page_instances: bool,
}
/// Sections in view (and distant tiles) from which a frame is drawn flat.
@@ -247,7 +259,8 @@ fn buffer(
/// instead of a destination: the call's room holds all of its draws. Draws
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too,
/// and phase 2's from `passes[2]` on (its cutout pass's).
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -255,7 +268,7 @@ fn plan(
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
passes: [usize; 3],
align: u64,
key: impl Fn(u32, Option<u32>) -> u32,
calls: &mut Vec<Call>,
@@ -267,7 +280,7 @@ fn plan(
segments.clear();
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut pass_calls = [None; 3];
let mut total = 0u64;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
@@ -329,14 +342,22 @@ fn plan(
phase1_calls,
solid_calls,
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
pass_calls: {
let [opaque, cutout, phase2] = pass_calls.map(|c| c.unwrap_or(calls.len()));
[
opaque.min(phase1_calls),
cutout.min(phase1_calls),
phase2.clamp(phase1_calls, solid_calls),
]
},
total,
}
}
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -349,13 +370,18 @@ impl Flat {
&str,
Option<wgpu::BlendState>,
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
let draw_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Flat section draws"),
entries: &[storage(0, vertex, true), storage(1, vertex, true)],
entries: &[
storage(0, vertex, true),
storage(1, vertex, true),
storage(ARENA_BINDING, vertex, true),
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Flat terrain layout"),
@@ -404,38 +430,44 @@ impl Flat {
let storage_usage = wgpu::BufferUsages::STORAGE;
let back = Some(wgpu::Face::Back);
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
&layout,
"Terrain solid (flat)",
"vs_terrain_flat",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
&layout,
"Terrain solid opaque (flat)",
"vs_terrain_flat_opaque",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
&layout,
"Terrain solid cutout (flat)",
"vs_terrain_flat_cutout",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
&layout,
"Terrain solid mirrored (flat)",
"vs_terrain_flat",
"vs_section_flat",
"fs_solid_mirror",
None,
Some(wgpu::Face::Front),
true,
),
translucent: pipeline(
&layout,
@@ -444,6 +476,7 @@ impl Flat {
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
false,
),
translucent_mark: pipeline(
&layout,
@@ -452,6 +485,7 @@ impl Flat {
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
false,
),
flatten,
compute_layout,
@@ -490,11 +524,12 @@ impl Flat {
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
pass_calls: [0; 2],
pass_calls: [0; 3],
shadow_calls: [0; 2],
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
page_instances: false,
}
}
/// A section uploaded to `vertices` vertices from `start` of the page keyed `page`:
@@ -539,13 +574,15 @@ impl Flat {
self.pages.resize_with(page + 1, || None);
}
let table = self.pages[page].get_or_insert_with(|| PageTable {
// Copied whole into the visibility buffer's table of every page (`visbuffer`).
blocks: buffer(
device,
"Section ids by block",
blocks * 4,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
),
group: None,
arena: None,
});
let first = start / GRAIN;
let ids = vec![id; vertices.div_ceil(GRAIN) as usize];
@@ -606,7 +643,7 @@ impl Flat {
phase1_draws: usize,
solid_draws: usize,
shadow: [usize; 2],
passes: [usize; 2],
passes: [usize; 3],
phase2: Option<&wgpu::Buffer>,
translucent_arena: bool,
origins: impl Iterator<Item = (u32, [f32; 4])>,
@@ -680,7 +717,7 @@ impl Flat {
self.phase1_calls = 0;
self.solid_calls = 0;
self.shadow_calls = [0; 2];
self.pass_calls = [0; 2];
self.pass_calls = [0; 3];
return false;
}
if self.range_data.len() as u64 > self.range_capacity {
@@ -709,10 +746,11 @@ impl Flat {
);
// Phase 2's calls start empty, each at the start of its room.
self.phase2_call_data.clear();
let page_instances = self.page_instances;
self.phase2_call_data.extend(
self.calls[phase1_calls..solid_calls]
.iter()
.map(|c| [0, 1, c.first, 0, 0]),
.map(|c| [0, 1, c.first, 0, if page_instances { c.page } else { 0 }]),
);
let phase2_calls = self.phase2_call_data.len() as u64;
if phase2_calls > self.phase2_call_capacity {
@@ -807,29 +845,53 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
for page in self.calls.iter().map(|c| c.page) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
if table.group.is_none() {
table.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flat section draws"),
layout: &self.draw_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: table.blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.origins.as_entire_binding(),
},
],
}));
}
}
true
}
/// Gives every page table its bind group (`bind_page`): its blocks, the origins and, for
/// a page of the section arena, that page (`arena`, by page) for the solid draws to read.
/// Before each frame's draws, flat or not: the per-section solid draws bind the groups too
/// (`page_group`), and a page that grew is another buffer.
pub fn bind_pages<'a>(
&mut self,
device: &wgpu::Device,
arena: impl Fn(u32) -> Option<&'a wgpu::Buffer>,
) {
for (key, slot) in self.pages.iter_mut().enumerate() {
let Some(table) = slot else {
continue;
};
let page = (key < TRANSLUCENT_KEYS as usize)
.then(|| arena(key as u32))
.flatten();
if table.group.is_some() && table.arena.as_ref() == page {
continue;
}
table.arena = page.cloned();
table.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flat section draws"),
layout: &self.draw_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: table.blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.origins.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: ARENA_BINDING,
resource: page.unwrap_or(&self.empty).as_entire_binding(),
},
],
}));
}
}
/// The bind group of the section arena's page `page` (`bind_pages`), which the solid
/// draws read their vertices through.
pub fn page_group(&self, page: u32) -> Option<&wgpu::BindGroup> {
self.pages.get(page as usize)?.as_ref()?.group.as_ref()
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(
&self,
@@ -879,6 +941,46 @@ impl Flat {
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// `draw`, each call as the instance its page key names: the visibility buffer's ID
/// pass (`visbuffer`) tells the pages apart by it.
pub(super) fn draw_paged<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for call in calls {
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed(
call.first..call.first + call.count,
0,
call.page..call.page + 1,
);
}
}
/// Bytes of its buffers: the index list, ranges, origins and block tables.
pub fn bytes(&self) -> u64 {
let tables: u64 = self.pages.iter().flatten().map(|t| t.blocks.size()).sum();
self.indices.size()
+ self.ranges.size()
+ self.origins.size()
+ self.phase2_calls.size()
+ tables
}
/// The origins by section id, which the visibility buffer's resolve (`visbuffer`) reads
/// in its fragment shader.
pub(super) fn origins(&self) -> &wgpu::Buffer {
&self.origins
}
/// The block table of the section arena's page `page` (`blocks_per_page` entries), which
/// the visibility buffer copies into its table of every page.
pub(super) fn block_table(&self, page: u32) -> Option<&wgpu::Buffer> {
Some(&self.pages.get(page as usize)?.as_ref()?.blocks)
}
pub(super) fn blocks_per_page(&self) -> u64 {
self.blocks_per_page
}
/// Draws `calls` without their index buffer, `instances` times: a shader pack's geometry
/// stage reads the indices itself (`hz_indices`) and stands for one emitted triangle per
/// instance.
@@ -896,19 +998,22 @@ impl Flat {
pass.draw(call.first..call.first + call.count, 0..instances);
}
}
/// Draws phase 2's calls, as `flatten_phase2` left them.
/// Draws phase 2's `calls` (`calls[phase1_calls..solid_calls]` counted from 0), as
/// `flatten_phase2` left them.
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: std::ops::Range<usize>,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a>,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
let first = self.phase1_calls + calls.start;
for (k, call) in self.calls[first..self.phase1_calls + calls.end]
.iter()
.enumerate()
{
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
pass.draw_indexed_indirect(&self.phase2_calls, (calls.start + k) as u64 * 20);
}
}
fn bind_page<'a>(
@@ -968,7 +1073,7 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1017,14 +1122,14 @@ mod tests {
solid,
solid,
[draws.len(); 2],
[3, 1],
[3, 1, solid],
4,
|page, _| page,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1, 4], 4));
let call = |page, first, count| Call { page, first, count };
assert_eq!(
calls,
@@ -1053,7 +1158,7 @@ mod tests {
solid,
solid,
[camera, camera + 2],
[camera, 0],
[camera, 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1082,7 +1187,7 @@ mod tests {
2,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
4,
|page, _| page,
&mut calls,
@@ -1095,7 +1200,7 @@ mod tests {
phase1_calls: 1,
solid_calls: 3,
shadow_calls: [4, 4],
pass_calls: [1, 0],
pass_calls: [1, 0, 3],
total: 6 * 13
}
);
@@ -1200,7 +1305,7 @@ mod tests {
phase1,
solid,
[draws.len(); 2],
[draws.len(), 0],
[draws.len(), 0, solid],
align,
|page, _| page,
&mut calls,
@@ -914,14 +914,23 @@ impl GpuCulling {
}
/// The kept sections' solid draws, a count draw per page, with the quad
/// indices bound. Leaves the origins at vertex slot 1.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, arena: &Arena) {
/// indices bound. Leaves the origins at vertex slot 1. `group`: the page's group 1 for
/// pipelines that read the page themselves (`Flat::page_group`), else none.
pub fn draw<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
arena: &Arena,
group: impl Fn(u32) -> Option<&'a wgpu::BindGroup>,
) {
if self.pages.is_empty() {
return;
}
pass.set_vertex_buffer(1, self.origins.buffer.slice(..));
for &(page, first, size) in &self.pages {
pass.set_vertex_buffer(0, arena.buffer(page).slice(..));
if let Some(group) = group(page) {
pass.set_bind_group(1, group, &[]);
}
pass.multi_draw_indexed_indirect_count(
&self.draws.buffer,
u64::from(first) * 20,
@@ -2214,7 +2223,7 @@ struct Quad {
});
pass.set_pipeline(&pipeline);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
gpu.draw(&mut pass, &arena);
gpu.draw(&mut pass, &arena, |_| None);
drop(pass);
encoder.copy_texture_to_buffer(
target.as_image_copy(),
+7 -5
View File
@@ -1,8 +1,10 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an HDR10 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.
//! without it, in vanilla's gamma space, into a 16-bit target when HDR output
//! was on when the renderer was made, else an 8-bit one
//! (`Renderer::frame_format`), and one pass (`hdr.wgsl`) writes it to an HDR10
//! 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.
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -301,7 +303,7 @@ impl Hdr {
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
@@ -0,0 +1,223 @@
//! Every render pipeline's stage interface against the weakest GPUs' limit (#8195).
//!
//! OpenGL 3.3 drivers (a GeForce 9100M G) allow `max_inter_stage_shader_variables` = 15, and wgpu
//! counts the fragment stage's built-in inputs (`front_facing`, `sample_index`, ...) against it.
//! A pipeline over that limit panics at start-up on such a GPU, with no way to run the game, so
//! this checks the shaders with naga, without a GPU.
use std::path::{Path, PathBuf};
/// `Limits::downlevel_defaults().max_inter_stage_shader_variables`, the least a GPU offers.
const LIMIT: u32 = 15;
/// One entry point's variables: the user-defined `@location`s and the built-ins wgpu counts.
#[derive(Debug, PartialEq, Eq)]
struct Interface {
entry: String,
/// Vertex outputs or fragment inputs, user-defined.
user: u32,
/// The fragment stage's `front_facing`, `sample_index`, `sample_mask` and `primitive_index`
/// inputs, which wgpu counts as one variable each; 0 for a vertex stage.
built_ins: u32,
}
impl Interface {
fn total(&self) -> u32 {
self.user + self.built_ins
}
}
/// The bindings an argument or a result carries, a struct's members flattened.
fn bindings(
module: &naga::Module,
binding: &Option<naga::Binding>,
ty: naga::Handle<naga::Type>,
) -> Vec<naga::Binding> {
if let Some(binding) = binding {
return vec![binding.clone()];
}
match &module.types[ty].inner {
naga::TypeInner::Struct { members, .. } => {
members.iter().filter_map(|m| m.binding.clone()).collect()
}
_ => Vec::new(),
}
}
fn is_location(binding: &naga::Binding) -> bool {
matches!(binding, naga::Binding::Location { .. })
}
/// The stage interface of every vertex and fragment entry point of `source`.
fn interfaces(source: &str) -> Vec<Interface> {
let module = naga::front::wgsl::parse_str(source).unwrap();
let mut out = Vec::new();
for entry in &module.entry_points {
let function = &entry.function;
let (user, built_ins) = match entry.stage {
naga::ShaderStage::Vertex => {
let outputs: Vec<_> = function
.result
.iter()
.flat_map(|r| bindings(&module, &r.binding, r.ty))
.collect();
(outputs.iter().filter(|b| is_location(b)).count(), 0)
}
naga::ShaderStage::Fragment => {
let inputs: Vec<_> = function
.arguments
.iter()
.flat_map(|a| bindings(&module, &a.binding, a.ty))
.collect();
let built_ins = inputs
.iter()
.filter(|b| {
matches!(
b,
naga::Binding::BuiltIn(
naga::BuiltIn::FrontFacing
| naga::BuiltIn::SampleIndex
| naga::BuiltIn::SampleMask
| naga::BuiltIn::PrimitiveIndex
)
)
})
.count();
(inputs.iter().filter(|b| is_location(b)).count(), built_ins)
}
_ => continue,
};
out.push(Interface {
entry: entry.name.clone(),
user: user as u32,
built_ins: built_ins as u32,
});
}
out
}
/// The shaders as the renderer assembles them, by the `.wgsl` files each is made of.
fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
vec![
("entity", vec!["entity.wgsl"], entity.clone()),
(
"static entity",
vec!["entity.wgsl", "static_entity.wgsl"],
entity.clone() + include_str!("../static_entity.wgsl"),
),
(
"improved transparency clouds",
vec!["entity.wgsl", "oit.wgsl", "oit_clouds.wgsl"],
entity.clone() + include_str!("../oit.wgsl") + include_str!("../oit_clouds.wgsl"),
),
(
"improved transparency features",
vec!["entity.wgsl", "oit.wgsl", "oit_features.wgsl"],
entity + include_str!("../oit.wgsl") + include_str!("../oit_features.wgsl"),
),
(
"improved transparency world",
vec!["terrain.wgsl", "oit.wgsl", "oit_world.wgsl"],
include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"),
),
(
"visibility buffer",
vec!["terrain.wgsl", "visbuffer.wgsl"],
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
),
(
"improved transparency world border",
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"),
),
]
}
fn wgsl_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
wgsl_files(&path, out);
} else if path.extension().is_some_and(|e| e == "wgsl") {
out.push(path);
}
}
}
/// Every shader: the assembled ones, and each `.wgsl` file that stands alone. A file that does
/// not (it needs another's declarations) must be part of an assembled one, so that a new shader
/// cannot slip past the audit.
fn audited() -> Vec<(String, Vec<Interface>)> {
let composed = composed();
let mut files = Vec::new();
wgsl_files(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
&mut files,
);
files.sort();
let mut out: Vec<(String, Vec<Interface>)> = composed
.iter()
.map(|(name, _, source)| ((*name).to_owned(), interfaces(source)))
.collect();
for file in files {
let name = file.file_name().unwrap().to_string_lossy().into_owned();
let source = std::fs::read_to_string(&file).unwrap();
let alone = naga::front::wgsl::parse_str(&source).is_ok();
let part = composed.iter().any(|(_, parts, _)| parts.contains(&&*name));
assert!(
alone || part,
"{name} needs other files' declarations: add its assembly to `composed`"
);
if alone {
out.push((name, interfaces(&source)));
}
}
out
}
#[test]
fn no_pipeline_exceeds_the_weakest_gpus_inter_stage_limit() {
let mut over = Vec::new();
for (shader, entries) in audited() {
for e in entries {
if e.total() > LIMIT {
over.push(format!(
"{shader}::{}: {} user-defined + {} built-in = {} > {LIMIT}",
e.entry,
e.user,
e.built_ins,
e.total()
));
}
}
}
assert!(over.is_empty(), "over the limit:\n{}", over.join("\n"));
}
/// The audit counts what wgpu counts (the failure of #8195: 15 user-defined + `FrontFacing`).
#[test]
fn a_front_facing_input_counts_against_the_limit() {
let source = "struct Io { @builtin(position) p: vec4<f32>, @location(0) a: vec4<f32>, @location(1) b: f32 }
@vertex fn vs() -> Io { return Io(vec4<f32>(0.0), vec4<f32>(0.0), 0.0); }
@fragment fn fs(in: Io, @builtin(front_facing) f: bool) -> @location(0) vec4<f32> { return in.a; }";
assert_eq!(
interfaces(source),
vec![
Interface {
entry: "vs".into(),
user: 2,
built_ins: 0
},
Interface {
entry: "fs".into(),
user: 2,
built_ins: 1
},
]
);
}
@@ -63,6 +63,9 @@ pub(super) struct Tile {
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
/// meshes of the same tile replace one another.
pub cull_slot: u32,
}
/// How long a tile takes to fade in over the ones it replaces.
@@ -160,6 +163,18 @@ impl Tile {
None => &mut self.mesh,
}
}
/// The box occlusion culling tests (`cull::Cull`), around its origin: its
/// mesh's bounds grown by a block, while that mesh is the one drawn
/// whole (no stand-in, no fade). A fading tile's dither moves its depth
/// toward the camera (terrain.wgsl's `fade_depth`), out of the box.
pub fn cull_bounds(&self, fade: u32) -> Option<[[f32; 3]; 2]> {
(fade == 0 && self.previous.is_none()).then(|| {
[
(self.bounds.min - 1.0).to_array(),
(self.bounds.max + 1.0).to_array(),
]
})
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
@@ -192,6 +207,10 @@ impl Renderer {
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
/// (`upload_lods`), then fades in.
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
if let Some(copies) = &mut self.capture_copies {
copies.tile(key, mesh);
}
let old_slot = self.lods.get(&key).map(|tile| tile.cull_slot);
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
@@ -212,6 +231,12 @@ impl Renderer {
};
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
Some((bounds, mesh)) => {
let cull_slot = old_slot.unwrap_or_else(|| {
self.lod_cull_free.pop().unwrap_or_else(|| {
self.lod_cull_next += 1;
self.lod_cull_next - 1
})
});
self.lods.insert(
key,
Tile {
@@ -221,6 +246,7 @@ impl Renderer {
dying: None,
alone: false,
previous,
cull_slot,
},
);
}
@@ -228,6 +254,7 @@ impl Renderer {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
self.lod_cull_free.extend(old_slot);
}
}
}
@@ -240,12 +267,19 @@ impl Renderer {
}
pub fn remove_lod(&mut self, key: TileKey) {
if let Some(copies) = &mut self.capture_copies {
copies.forget_tile(key);
}
if let Some(tile) = self.lods.remove(&key) {
self.lod_cull_free.push(tile.cull_slot);
self.release_tile(tile);
}
}
pub fn clear_lods(&mut self) {
if let Some(copies) = &mut self.capture_copies {
copies.clear_tiles();
}
self.lod_pending = None;
// No far terrain left to fade out over.
for (key, since) in std::mem::take(&mut self.lod_leaving) {
@@ -260,6 +294,8 @@ impl Renderer {
for (_, tile) in std::mem::take(&mut self.lods) {
self.release_tile(tile);
}
self.lod_cull_free.clear();
self.lod_cull_next = 0;
self.set_lod_cutout(Default::default());
}
@@ -437,6 +473,15 @@ impl Renderer {
}
}
/// The tiles drawn for good, not fading out (`render-capture`).
pub(super) fn standing_lods(&self) -> FxHashSet<TileKey> {
self.lods
.iter()
.filter(|(_, tile)| tile.dying.is_none())
.map(|(&key, _)| key)
.collect()
}
/// The chunk columns the resident tiles now leave out (`Lod::take_cutout`).
/// Removed sections they no longer leave out are released.
pub fn set_lod_cutout(&mut self, cutout: crate::fast_hash::FxHashSet<(i32, i32)>) {
@@ -6,7 +6,7 @@ use horizon_shader_api::{Report, StageKind};
use super::displace::Displacement;
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_shadow::{NativeShadow, ShadowGrid};
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -94,13 +94,33 @@ impl NativePack {
.max(1);
let steps =
d.steps_option.is_some() && option_on(constants, d.steps_option.as_ref());
Displacement::new(device, d.distance, cells, steps, &frame.pbr[0], false)
Displacement::new(
device,
d.distance,
cells,
steps,
&frame.pbr[0],
report.shadow.is_some(),
)
});
let shadow = report
.shadow
.as_ref()
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
// `[displacement]`'s grid casts too, from the material's module.
let material = report.stages.iter().find(|s| s.kind == StageKind::Material);
let grid = displacement.as_ref().and_then(|d| {
let picks = d.shadow.as_ref()?;
Some(ShadowGrid {
distance: d.distance,
cells: picks.cells,
steps: picks.steps,
material: material?,
layout: &d.draw_layout,
normals: &frame.pbr[0],
})
});
NativeShadow::new(
device,
terrain,
@@ -108,6 +128,7 @@ impl NativePack {
&s.solid_materials,
vertex,
constants,
grid,
)
})
.transpose()?;
@@ -23,6 +23,9 @@ struct HorizonShadowOut {
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
// The point on the flat face, camera-relative (`[displacement]`'s grid takes it over near
// the camera).
@location(4) base: vec3<f32>,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
@@ -37,6 +40,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
out.base = pos;
return out;
}
@vertex fn horizon_shadow_vs(
@@ -58,6 +62,10 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
if horizon_shadow_taken(in.base, in.layer) { discard; }
horizon_shadow_cutout(in);
}
fn horizon_shadow_cutout(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
@@ -105,6 +113,45 @@ struct HorizonEntityShadowOut {
}
";
/// No `[displacement]` grid: the flat terrain casts everywhere.
const UNTAKEN: &str = "
fn horizon_shadow_taken(base: vec3<f32>, layer: u32) -> bool {
return false;
}
";
/// `[displacement]`'s grid in the shadow map (gfx/displace.rs), cast in the flat quads' place.
/// The height maps join the pass's frame group.
const GRID_ENTRIES: &str = "
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
fn horizon_shadow_taken(base: vec3<f32>, layer: u32) -> bool {
return length(base) < horizon_displace_distance - 0.05 && horizon_displaced_layer(layer);
}
@vertex fn horizon_shadow_vs_displaced(@builtin(vertex_index) v: u32,
@builtin(instance_index) instance: u32) -> HorizonShadowOut {
let p = horizon_displace_point(v, instance);
var out = horizon_shadow_vertex(p.position, p.uv, p.layer, p.color);
out.base = p.base;
return out;
}
@fragment fn horizon_shadow_fs_displaced(in: HorizonShadowOut) {
if length(in.base) >= horizon_displace_distance + 0.05 { discard; }
horizon_shadow_cutout(in);
}
";
/// What the shadow pass's grid is built from: `[displacement]`'s values, the material stage
/// (whose module holds `displacement` and the vertex stage), group 1's layout and the height
/// maps.
pub struct ShadowGrid<'a> {
pub distance: f32,
pub cells: u32,
pub steps: bool,
pub material: &'a horizon_shader_api::check::CheckedStage,
pub layout: &'a wgpu::BindGroupLayout,
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -326,6 +373,8 @@ pub struct NativeShadow {
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
/// `[displacement]`'s grid (gfx/displace.rs's shadow list).
pub displaced: Option<wgpu::RenderPipeline>,
}
/// The entity draw's shader.
@@ -418,17 +467,36 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
/// `solid` of `shadow_solid`. Under `[displacement]` (`grid`: the material stage's source, which
/// holds the vertex stage and `displacement`), its grid too.
fn shader_source(
solid: &str,
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
(Some(material), _) => (
format!(
"{material}{}{table}{}{}{GRID_ENTRIES}",
horizon_shader_api::stages::PBR_STUB,
super::displace::RELIEF,
super::displace::GRID,
),
if vertex.is_some() {
super::native_material::DISPLACE_PACK
} else {
super::native_material::DISPLACE_VANILLA
},
),
(None, Some(stage)) => (
format!("{stage}{table}{UNTAKEN}"),
super::native_material::DISPLACE_PACK,
),
None => (
(None, None) => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
@@ -452,11 +520,13 @@ impl NativeShadow {
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
grid: Option<ShadowGrid<'_>>,
) -> Result<Self, String> {
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -465,31 +535,59 @@ impl NativeShadow {
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
]);
if let Some(g) = &grid {
constants.extend([
("horizon_displace_distance", f64::from(g.distance)),
("horizon_displace_cells", f64::from(g.cells)),
("horizon_displace_steps", f64::from(u8::from(g.steps))),
]);
}
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
// The frame alone, and under `[displacement]` the height maps its grid moves by.
let frame_entry = wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
};
let normals_entry = wgpu::BindGroupLayoutEntry {
binding: 12,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
};
let layout_entries: Vec<_> = std::iter::once(frame_entry)
.chain(grid.is_some().then_some(normals_entry))
.collect();
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
entries: &layout_entries,
});
let group_entries: Vec<_> = std::iter::once(wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
})
.chain(grid.as_ref().map(|g| wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(g.normals),
}))
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
entries: &group_entries,
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
@@ -519,10 +617,11 @@ impl NativeShadow {
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let pipeline_with = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment: &str| {
let translucent = fragment == "horizon_shadow_fs_translucent";
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -556,11 +655,7 @@ impl NativeShadow {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
entry_point: Some(fragment),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
@@ -568,6 +663,17 @@ impl NativeShadow {
cache: None,
})
};
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let fragment = if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
};
pipeline_with(layout, vertex, buffers, fragment)
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
@@ -595,6 +701,15 @@ impl NativeShadow {
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
// The grid's corners come from its group 1, not from vertex buffers.
let displaced = grid.as_ref().map(|g| {
pipeline_with(
&layout(Some(g.layout)),
"horizon_shadow_vs_displaced",
&[],
"horizon_shadow_fs_displaced",
)
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
@@ -605,6 +720,7 @@ impl NativeShadow {
flat_translucent,
entity,
group,
displaced,
})
}
}
@@ -616,7 +732,7 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -627,6 +743,47 @@ mod tests {
}
}
#[test]
fn the_shadow_shader_with_the_displaced_grid_validates() {
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"material.wgsl\"\n[shadow]\n[displacement]\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo);\n}\n\
fn displacement(input: DisplacementInput) -> f32 {\n \
return (1.0 - input.height) * 0.125;\n}\n";
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report
.stages
.iter()
.find(|s| s.kind == StageKind::Material)
.unwrap();
let materials = [(3, 7)];
let text = shader_source(
&shadow_solid(&materials, &[7]),
None,
&materials,
Some(&stage.source),
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
for entry in ["horizon_shadow_vs_displaced", "horizon_shadow_fs_displaced"] {
assert!(
module.entry_points.iter().any(|e| e.name == entry),
"{entry}"
);
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
@@ -26,6 +26,37 @@ impl PackAtlas {
split: u32,
layers: u32,
sprite: u32,
) -> Option<Self> {
Self::with_levels(
device,
encoder,
arrays,
split,
layers,
sprite,
arrays[0].mip_level_count(),
)
}
/// Encoded material channels must select an existing texel, including at distance.
pub fn material(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
) -> Option<Self> {
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
}
fn with_levels(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
arrays: &[wgpu::Texture],
split: u32,
layers: u32,
sprite: u32,
levels: u32,
) -> Option<Self> {
let layers = layers.max(1);
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
@@ -35,7 +66,7 @@ impl PackAtlas {
return None;
}
// Levels past a sprite's single pixel would mix neighbours: stop there.
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
let levels = levels.min(sprite.ilog2() + 1);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack block atlas"),
size: wgpu::Extent3d {
@@ -252,6 +283,7 @@ impl PackAtlas {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
}
@@ -58,7 +58,8 @@ impl EntitySpan {
}
}
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
/// `entityColor`.
const SIZE: u32 = 32;
pub(super) struct DrawUniforms {
@@ -73,8 +74,10 @@ impl DrawUniforms {
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
// `blockEntityId` outside block entities, as Iris has it.
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
}
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after: String::new(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -194,6 +196,8 @@ pub(super) struct Draw {
/// The pack's shadow program draws it into the shadow map too.
pub shadow: bool,
pub slot: u32,
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
pub portal: bool,
}
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
@@ -375,38 +379,89 @@ impl PackEntities {
pack,
casts,
overlay,
false,
);
}
}
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
rest.extend_from_slice(&entities[..first.min(entities.len())]);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
for (span, range) in runs(entities.len(), spans) {
let run = &entities[range];
let program = program_of(&span).filter(|&p| {
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
});
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
let casts = program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
rest.extend_from_slice(run);
}
let Some(program) = program.filter(|_| (pack || casts) && !run.is_empty()) else {
continue;
let whole = &entities[range];
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
// is theirs), for gbuffers_beaconbeam.
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
&& whole
.chunks(4)
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
let parts: [(&[Vertex], Part); 3] = if split {
other.clear();
portal_faces.clear();
beam_faces.clear();
for quad in whole.chunks(4) {
match quad[0].layer & 0xffff {
9 | 10 => portal_faces.extend_from_slice(quad),
6 => beam_faces.extend_from_slice(quad),
_ => other.extend_from_slice(quad),
}
}
[
(&other, Part::Plain),
(&portal_faces, Part::Portal),
(&beam_faces, Part::Beam),
]
} else {
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
};
let start = self.scratch.len() as u32;
self.scratch.extend_from_slice(run);
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
for (run, part) in parts {
if run.is_empty() {
continue;
}
let program = match part {
Part::Portal => Some(BLOCK),
Part::Beam => Some(BEAM),
Part::Plain => program_of(&span).filter(|&p| {
plain(run)
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
}),
};
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
// draws them); beams do not.
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
if !pack && !span.hidden {
extend_without_portals(rest, run);
}
let Some(program) = program.filter(|_| pack || casts) else {
continue;
};
let start = self.scratch.len() as u32;
match part {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
if part == Part::Portal {
as_portal_texture(&mut self.scratch[start as usize..]);
}
let vertices = self.scratch.len() as u32 - start;
let overlay = self.scratch[start as usize].layer & 0xffff;
let portal = part == Part::Portal;
self.push_draw(
span,
posed + start,
vertices,
program,
pack,
casts,
overlay,
portal,
);
}
self.push_draw(
span,
posed + start,
run.len() as u32,
program,
pack,
casts,
run[0].layer & 0xffff,
);
}
if self.draws.is_empty() && posed == 0 {
return;
@@ -453,6 +508,7 @@ impl PackEntities {
pack: bool,
casts: bool,
overlay: u32,
portal: bool,
) {
let (entity_id, block_id) = match program {
BLOCK => (0, span.id),
@@ -476,6 +532,7 @@ impl PackEntities {
pack,
shadow: casts,
slot: self.draws.len() as u32,
portal,
});
}
}
@@ -504,13 +561,104 @@ fn plain(run: &[Vertex]) -> bool {
.all(|v| !matches!(v.layer & 0xffff, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// What part of a block entity's run goes to which program (`prepare`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Part {
Plain,
Portal,
Beam,
}
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
/// beam, the sprite's v range in the light bytes) for a pack: each face cut where its texture
/// repeats, so every piece holds one copy of the sprite in plain atlas coordinates (at most 64
/// pieces a face; a taller beam stretches its copies), white light and its colour kept.
fn beam_segments(run: &[Vertex], out: &mut Vec<Vertex>) {
for quad in run.as_chunks::<4>().0 {
let wrap = quad[0].light;
let (v0, size) = (
(wrap & 65535) as f32 / 65535.0,
(wrap >> 16) as f32 / 65535.0,
);
// Corners 0 and 3 at the beam's end (v1), 1 and 2 at its start (v2); 0 and 1 on one
// side, 2 and 3 on the other.
let (v1, v2) = (quad[0].uv[1], quad[1].uv[1]);
if (v1 - v2).abs() < 1e-6 {
continue;
}
let (lo, hi) = (v1.min(v2), v1.max(v2));
let first = lo.floor();
let repeats = (hi.ceil() - first).max(1.0);
let step = (repeats / 64.0).ceil().max(1.0);
let pieces = (repeats / step).ceil() as u32;
let at = |side: usize, v: f32| {
let (a, b) = if side == 0 {
(quad[1], quad[0])
} else {
(quad[2], quad[3])
};
let t = (v - v2) / (v1 - v2);
glam::Vec3::from(a.pos)
.lerp(glam::Vec3::from(b.pos), t)
.to_array()
};
for k in 0..pieces {
let base = first + k as f32 * step;
let (a, b) = (base.max(lo), (base + step).min(hi));
if b <= a {
continue;
}
// The end-side corners take the piece's bound nearer v1.
let (end_v, start_v) = if v1 > v2 { (b, a) } else { (a, b) };
for (i, corner) in quad.iter().enumerate() {
let side = usize::from(i >= 2);
let v = if i == 0 || i == 3 { end_v } else { start_v };
let mut out_v = *corner;
out_v.pos = at(side, v);
out_v.uv = [corner.uv[0], v0 + (v - base) / step * size];
out_v.layer = 0;
out_v.light = 0x0000_f0f0;
out.push(out_v);
}
}
}
}
/// `run` added to Horizon's own draw without End portal faces: under a shader pack Horizon's
/// entity shader would put its screen-projected starfield into the pack's buffers as if they
/// were its own frame (misplaced where the pack renders at another size), so a portal the pack
/// does not draw (its programs not there, or not yet) is better left out.
fn extend_without_portals(rest: &mut Vec<Vertex>, run: &[Vertex]) {
for quad in run.chunks(4) {
if !quad.iter().all(|v| matches!(v.layer & 0xffff, 6 | 9 | 10)) {
rest.extend_from_slice(quad);
}
}
}
/// End portal faces for a pack's `gbuffers_block` (block entities): each face textured with the
/// whole of `end_portal.png`, which the draw binds as the atlas (a texture of its own, so a
/// pack's own portal effect can wrap it), white, at full light; the pack tells the portal by its
/// `blockEntityId`.
fn as_portal_texture(run: &mut [Vertex]) {
const CORNERS: [[f32; 2]; 4] = [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]];
for (i, v) in run.iter_mut().enumerate() {
v.uv = CORNERS[i % 4];
v.layer = 0;
v.color = 0xFFFF_FFFF;
v.light = 0x0000_f0f0;
}
}
/// The programs of entity runs, in the order they draw.
pub const ENTITY: usize = 0;
pub const BLOCK: usize = 1;
pub const TRANSLUCENT: usize = 2;
pub const PARTICLE: usize = 3;
pub const DESTROY: usize = 4;
pub const PROGRAMS: usize = 5;
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
pub const BEAM: usize = 5;
pub const PROGRAMS: usize = 6;
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
/// `None`: Horizon draws it.
@@ -587,11 +735,11 @@ mod tests {
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
assert_eq!(
drawn_by_pack(&spans[1], [true, true, true, false, true]),
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
None
);
assert_eq!(
drawn_by_pack(&spans[2], [true, false, true, true, true]),
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
None
);
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
@@ -654,6 +802,103 @@ fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
}
}
#[cfg(test)]
mod portal_beam_tests {
use super::*;
fn quad(layer: u32, light: u32, corners: [([f32; 3], [f32; 2]); 4]) -> [Vertex; 4] {
corners.map(|(pos, uv)| Vertex {
pos,
uv,
layer,
color: 0xFF20_40FF,
light,
..bytemuck::Zeroable::zeroed()
})
}
#[test]
fn a_beam_face_becomes_one_sprite_copy_per_repeat() {
// `block_entity_render::beam`'s corner order: 0 and 3 at the end (v1), 1 and 2 at the
// start (v2); the sprite's v range (0.25, height 0.5) in the light bytes.
let wrap = (0.25f32 * 65535.0).round() as u32 | ((0.5f32 * 65535.0).round() as u32) << 16;
let face = quad(
6,
wrap,
[
([0.0, 3.0, 0.0], [0.9, 3.0]),
([0.0, 0.0, 0.0], [0.9, 0.0]),
([1.0, 0.0, 0.0], [0.1, 0.0]),
([1.0, 3.0, 0.0], [0.1, 3.0]),
],
);
let mut out = Vec::new();
beam_segments(&face, &mut out);
assert_eq!(out.len(), 12, "three repeats, three pieces");
for (k, piece) in out.chunks(4).enumerate() {
let (bottom, top) = (k as f32, k as f32 + 1.0);
assert_eq!([piece[0].pos[1], piece[1].pos[1]], [top, bottom]);
assert_eq!([piece[2].pos[1], piece[3].pos[1]], [bottom, top]);
for v in piece {
assert!((0.25 - 1e-4..=0.75 + 1e-4).contains(&v.uv[1]), "{:?}", v.uv);
assert_eq!(v.layer, 0);
assert_eq!(v.light, 0x0000_f0f0);
assert_eq!(v.color, 0xFF20_40FF);
}
assert!((piece[0].uv[1] - 0.75).abs() < 1e-4 && (piece[1].uv[1] - 0.25).abs() < 1e-4);
assert_eq!([piece[0].uv[0], piece[2].uv[0]], [0.9, 0.1]);
}
}
#[test]
fn a_very_tall_beam_is_cut_into_at_most_64_pieces() {
let face = quad(
6,
0xFFFF_0000,
[
([0.0, 2000.0, 0.0], [1.0, 2000.0]),
([0.0, 0.0, 0.0], [1.0, 0.0]),
([1.0, 0.0, 0.0], [0.0, 0.0]),
([1.0, 2000.0, 0.0], [0.0, 2000.0]),
],
);
let mut out = Vec::new();
beam_segments(&face, &mut out);
assert!(out.len() / 4 <= 64, "{} pieces", out.len() / 4);
let top = out.iter().map(|v| v.pos[1]).fold(f32::MIN, f32::max);
let bottom = out.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
assert_eq!(
(bottom, top),
(0.0, 2000.0),
"the pieces still span the whole beam"
);
}
#[test]
fn portal_faces_take_the_whole_portal_texture_at_full_light() {
let mut face = quad(9, 0, [([0.0; 3], [0.0; 2]); 4]).to_vec();
as_portal_texture(&mut face);
let uvs: Vec<[f32; 2]> = face.iter().map(|v| v.uv).collect();
assert_eq!(uvs, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
assert!(
face.iter()
.all(|v| v.layer == 0 && v.color == 0xFFFF_FFFF && v.light == 0x0000_f0f0)
);
}
#[test]
fn horizon_keeps_no_portal_or_beam_face_under_a_pack() {
let mut run = Vec::new();
for layer in [0, 6, 9, 10, 2] {
run.extend(quad(layer, 0, [([0.0; 3], [0.0; 2]); 4]));
}
let mut rest = Vec::new();
extend_without_portals(&mut rest, &run);
let kept: Vec<u32> = rest.chunks(4).map(|q| q[0].layer).collect();
assert_eq!(kept, [0, 2]);
}
}
#[cfg(test)]
mod crack_projection_tests {
use super::*;
@@ -791,6 +1036,57 @@ mod gpu_span_tests {
assert_eq!(skin.spans.len(), 1);
}
#[test]
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
// One block entity: a plain face, then an End portal face (overlay layer 9).
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
.into_iter()
.map(|layer| Vertex {
layer,
..bytemuck::Zeroable::zeroed()
})
.collect();
let spans = [EntitySpan {
start: 0,
id: 99,
stage: render_stage("BLOCK_ENTITIES"),
..Default::default()
}];
let mut rest = Vec::new();
geometry.prepare(
&device,
&queue,
&layout.layout,
&cpu,
&spans,
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
);
assert_eq!(
(geometry.draws.iter())
.map(|d| (d.portal, d.shadow))
.collect::<Vec<_>>(),
[(false, true), (true, true)],
"the portal face casts as its block entity does"
);
}
#[test]
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
let instance = crate::gfx::test_gpu::instance();
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
/// `HzHand`: the hand's projection and its inverse.
const UNIFORM: u64 = 2 * 64;
/// `HzDraw`'s size (pack_draw.rs).
const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
);
@@ -45,10 +50,17 @@ pub struct PackHand {
capacity: u32,
/// Quads uploaded this frame.
pub quads: u32,
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
slots: wgpu::Buffer,
pub slot_group: wgpu::BindGroup,
stride: u64,
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
pub runs: Vec<(u32, u32, u32)>,
}
impl PackHand {
pub fn new(device: &wgpu::Device) -> Self {
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
@@ -74,6 +86,28 @@ impl PackHand {
});
let capacity = 256;
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
// The arm and the two hands' items: a few runs a frame.
let slots = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack hand draws"),
size: SLOTS * stride,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack hand draws"),
layout: draws,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &slots,
offset: 0,
size: wgpu::BufferSize::new(SLOT),
}),
}],
});
Self {
indices: super::pack_entities::quad_indices(device, capacity),
layout,
@@ -82,6 +116,10 @@ impl PackHand {
group,
capacity,
quads: 0,
slots,
slot_group,
stride,
runs: Vec::new(),
}
}
@@ -116,18 +154,31 @@ impl PackHand {
(buffer, group)
}
/// Uploads this frame's hand and its projection.
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hand: &[Vertex],
items: &[(u32, i32)],
projection: Mat4,
) {
self.quads = (hand.len() / 4) as u32;
self.runs.clear();
if self.quads == 0 {
return;
}
let mut contents = Vec::new();
for (start, quads, id) in runs(self.quads, items) {
let slot = (self.runs.len() as u64).min(SLOTS - 1);
if slot as usize == self.runs.len() {
contents.extend_from_slice(&draw_slot(id));
contents.resize(((slot + 1) * self.stride) as usize, 0);
}
self.runs.push((start, quads, (slot * self.stride) as u32));
}
queue.write_buffer(&self.slots, 0, &contents);
if self.quads > self.capacity {
self.capacity = self.quads.next_power_of_two();
(self.vertices, self.group) =
@@ -144,10 +195,54 @@ impl PackHand {
}
}
/// The hand's runs a frame keeps slots for.
const SLOTS: u64 = 8;
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
/// entities, and `currentRenderedItemId`.
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
let mut bytes = [0u8; SLOT as usize];
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
bytes[12..16].copy_from_slice(&id.to_le_bytes());
bytes
}
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
for &(start, id) in items {
let q = (start / 4).min(quads);
match starts.last_mut() {
Some(last) if last.0 == q => last.1 = id,
_ => starts.push((q, id)),
}
}
(0..starts.len())
.map(|i| {
let end = starts.get(i + 1).map_or(quads, |s| s.0);
(starts[i].0, end - starts[i].0, starts[i].1)
})
.filter(|r| r.1 > 0)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
#[test]
fn runs_split_the_hand_by_item() {
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
// An item that drew nothing gives way to the next.
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
}
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
/// apart by it), and x, y match the plain projection.
#[test]
@@ -35,7 +35,9 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
defs.into_iter()
.map(|def| PackImage {
format: format(def.format),
dimension: if def.is_3d() {
dimension: if def.is_1d() {
wgpu::TextureViewDimension::D1
} else if def.is_3d() {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
@@ -51,6 +53,15 @@ pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
.collect()
}
/// The texture dimension a view of `view` needs.
fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension {
match view {
wgpu::TextureViewDimension::D1 => wgpu::TextureDimension::D1,
wgpu::TextureViewDimension::D3 => wgpu::TextureDimension::D3,
_ => wgpu::TextureDimension::D2,
}
}
/// The wgpu format of an image's internal format: a storage format of the same channels.
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
@@ -85,7 +96,9 @@ pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), S
));
}
let limits = device.limits();
let largest = if def.is_3d() {
let largest = if def.is_1d() {
limits.max_texture_dimension_1d
} else if def.is_3d() {
limits.max_texture_dimension_3d
} else {
limits.max_texture_dimension_2d
@@ -128,11 +141,7 @@ pub(super) fn resize_images(
},
mip_level_count: 1,
sample_count: 1,
dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
dimension: texture_dimension(image.dimension),
format: image.format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | usage,
view_formats: &[],
@@ -321,11 +330,7 @@ pub(super) fn stand_ins(
},
mip_level_count: 1,
sample_count: 1,
dimension: if dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
dimension: texture_dimension(dimension),
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
.collect(),
after,
alpha_test: None,
item_id_declared: false,
}
}
@@ -0,0 +1,257 @@
//! Original synthetic material transport oracle; no external shader algorithms or assets.
use super::*;
use wgpu::util::DeviceExt;
#[test]
#[ignore = "requires a host GPU; missing adapter is a failure"]
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Material transport GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.unwrap();
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
std::fs::create_dir_all(root.join("shaders")).unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.vsh"),
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
)
.unwrap();
std::fs::write(
root.join("shaders/gbuffers_terrain.fsh"),
r#"#version 120
uniform sampler2D gtexture;
uniform sampler2D normals;
uniform sampler2D specular;
void main() {
int x = int(floor(gl_FragCoord.x));
int layer = x - (x / 5) * 5;
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
}
"#,
)
.unwrap();
let (split, layers) = (2u32, 5u32);
let mut encoder = device.create_command_encoder(&Default::default());
let atlases: Vec<_> = (0..3)
.map(|kind| {
let side = if kind == 0 { 4 } else { 2 };
let levels = if kind == 0 { 3 } else { 1 };
let arrays: Vec<_> = (0..layers.div_ceil(split))
.map(|array| {
let count = (layers - array * split).min(split);
let pixels: Vec<u8> = (0..count)
.flat_map(|l| {
let layer = (array * split + l) as u8;
(0..levels).flat_map(move |level| {
let edge = side >> level;
(0..edge * edge).flat_map(move |pixel| {
if level != 0 {
// Sampling unused source mip levels must fail the oracle.
[211, 212, 213, 214]
} else {
match kind {
0 if layer == 3 => crate::sprites::NO_SPECULAR,
0 => [
17 + layer,
if pixel % 2 == 0 { 0 } else { 240 },
65,
if pixel % 2 == 0 { 0 } else { 255 },
],
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
1 => [128 + layer, 126, 255, 19 + layer],
_ => [40 + layer, 50, 60, 255],
}
}
})
})
})
.collect();
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some("Original fixture companion array"),
size: wgpu::Extent3d {
width: side,
height: side,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&pixels,
)
})
.collect();
super::super::pack_atlas::PackAtlas::material(
&device,
&mut encoder,
&arrays,
split,
layers,
)
.unwrap()
})
.collect();
queue.submit([encoder.finish()]);
let pack = horizon_shaders::Pack::open(&root).unwrap();
let macros = horizon_shaders::Macros::standard();
// Encoded zero alpha is data here and must remain observable in readback.
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", &macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
..Default::default()
}),
..Default::default()
};
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs);
assert!(post.terrain.is_some(), "{:?}", post.notes);
let world = World {
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
lightmap: atlases[2].view.clone(),
terrain: Some(Geometry {
buffers: &[],
table: None,
depth_write: true,
atlas: None,
material_maps: true,
ids: None,
cull: false,
}),
block_ids: &[],
celestial: None,
gui_size: [1, 1],
clouds: None,
weather: false,
hand: false,
};
let mut post = PackPost::new(
&device,
&queue,
&adapter,
post,
wgpu::TextureFormat::Rgba8Unorm,
world,
);
let mut pass = post
.terrain
.take()
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
// Request the existing copy usage for the fixture's colour readback.
post.copied |= 1;
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
for case in 0..3 {
if case == 1 {
pass.material_maps = false;
}
if case == 2 {
pass.material_maps = true;
post.material_maps = None;
}
let maps_expected = case == 0;
pass.groups.clear();
post.resize(&device, [15, 1]);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Initialize fixture depth"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &post.targets.as_ref().unwrap().depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
}
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
{
let mut render = post.begin(&mut encoder, &pass);
render.set_pipeline(pass.render());
render.set_bind_group(0, &group, &[]);
if pass.draw {
post.draws.bind(&mut render, "TERRAIN_SOLID");
}
render.draw(0..3, 0..1);
}
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout {
bytes_per_row: Some(256),
..Default::default()
},
},
wgpu::Extent3d {
width: 15,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
for i in 0..15usize {
let layer = (i % 5) as u8;
let expected = if i >= 10 {
[40 + layer, 50, 60, 255]
} else if i >= 5 && (!maps_expected || layer == 2) {
crate::sprites::FLAT_NORMAL
} else if i >= 5 {
[128 + layer, 126, 255, 19 + layer]
} else if !maps_expected || layer == 3 {
crate::sprites::NO_SPECULAR
} else {
[17 + layer, 240, 65, 255]
};
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
}
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
}
// Fault injection: the same production binding must refuse a filtering sampler.
// If a regression changed both the binding and sampler, the fractional positive
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
pass.material_maps = true;
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
pass.groups.clear();
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
let error = pollster::block_on(validation.pop())
.expect("production material binding must reject a filtering sampler");
assert!(
error.to_string().to_ascii_lowercase().contains("filtering"),
"{error}"
);
eprintln!("Material oracle negative filtering control: rejected by production binding");
std::fs::remove_dir_all(root).unwrap();
}
+458 -75
View File
@@ -5,12 +5,13 @@
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
//!
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
//! written last (OQ-8). A cleared buffer has both textures cleared at the start of the frame:
//! with an odd number of flips a frame, the texture a program writes was "main" the frame before
//! and may still hold what was stored into it after that frame's clear (Iris 1.11.7 blends onto
//! the clear value there, probed with a pack of our own).
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). Every
//! frame starts with A as "main". A cleared buffer has both textures cleared then (Iris 1.11.7
//! reads and blends onto the clear value where a program leaves pixels, probed with a pack of our
//! own). A buffer that is not cleared reads back what was written last (OQ-8): when the last
//! frame ended on B, B is copied to A, and B keeps what was last written into it, which is what
//! a write leaves alone or blends onto there (a model that fits Iris 1.11.7's values in probe
//! packs of our own).
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
@@ -34,6 +35,9 @@ mod world_target_tests;
#[cfg(test)]
mod higher_shadowcolor_tests;
#[cfg(test)]
mod forced_flip_tests;
/// The texture behind a pack sampler name.
#[derive(Clone, Copy, PartialEq)]
enum Source {
@@ -96,22 +100,19 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Self::Nearest
}
// shadowtex0/1 through a plain sampler: linear, as Iris 1.11.7 reads them, when the
// device filters their copies (`ShadowTargets::linear`).
Source::ShadowDepth(_, false) if filtering => Self::Linear,
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
Source::Atlas => Self::NearestRepeat,
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
Source::Image(_) if filtering => Self::Linear,
Source::Image(_) => Self::Nearest,
Source::Custom { blur, clamp, .. } => match (blur && filtering, clamp) {
@@ -120,12 +121,9 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
}
}
@@ -366,11 +364,14 @@ struct Pass {
draw: bool,
/// What `texture` reads instead of the block atlas.
atlas: Option<wgpu::TextureView>,
material_maps: bool,
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
ids: bool,
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
/// 16–31) and the shadowcolor sides read (bits 32–33).
groups: HashMap<u64, wgpu::BindGroup>,
/// The same for End portal draws, with `end_portal.png` bound as the atlas.
portal_groups: HashMap<u64, wgpu::BindGroup>,
/// (binding, target) of each image: for a buffer, the side its sampler reads.
images: Vec<(u32, Image)>,
/// Binds the pack's storage buffers in set 4.
@@ -435,6 +436,7 @@ struct Targets {
pub(super) struct World<'a> {
/// The block atlas and its size.
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
pub material_maps: Option<[wgpu::TextureView; 2]>,
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
@@ -520,8 +522,9 @@ pub struct PackPost {
lines: Option<Pass>,
line_draw: Option<Lines>,
cloud_draw: Option<Clouds>,
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
/// clouds discards vanilla's), or `clouds=off`: the game's clouds are not drawn.
/// The pack draws the clouds (`gbuffers_clouds` or its fallback), even with a program that
/// writes nothing (a pack drawing its own clouds discards vanilla's), or `clouds=off`: the
/// game's clouds are not drawn.
hides_clouds: bool,
/// The pack has a sky (`gbuffers_skybasic`) or sun and moon (`gbuffers_skytextured`)
/// program: vanilla's stays hidden even when the program writes nothing.
@@ -545,6 +548,10 @@ pub struct PackPost {
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
shadow_terrain: Option<Pass>,
/// `end_portal.png`'s atlas rect this frame, and a texture of its own copied from it that
/// End portal draws bind as their atlas.
portal_rect: [f32; 4],
portal_texture: Option<([f32; 4], wgpu::TextureView)>,
shadow_water: Option<Pass>,
shadow_comp: Vec<Pass>,
/// Buffers with mipmaps (filterable ones a program asks them for).
@@ -567,6 +574,7 @@ pub struct PackPost {
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
material_maps: Option<[wgpu::TextureView; 2]>,
lightmap: wgpu::TextureView,
deferred_pre: u16,
composite_pre: u16,
@@ -611,7 +619,9 @@ pub struct PackPost {
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
nearest_repeat: wgpu::Sampler,
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
/// nor buffer to clear.
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
blit_depth: bool,
blit_clears: Vec<usize>,
@@ -933,6 +943,14 @@ fn blend_state(b: horizon_shaders::Blend) -> wgpu::BlendState {
}
}
/// A buffer whose texels are floats (normalized or not), not integers.
fn holds_floats(format: wgpu::TextureFormat) -> bool {
matches!(
format.sample_type(None, None),
Some(wgpu::TextureSampleType::Float { .. })
)
}
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
Some(wgpu::ColorTargetState {
format,
@@ -1054,6 +1072,8 @@ pub(super) struct Geometry<'a> {
pub depth_write: bool,
/// What `texture` reads instead of the block atlas.
pub atlas: Option<wgpu::TextureView>,
/// This draw uses the block atlas's surface identities.
pub material_maps: bool,
/// `mc_Entity`'s table at set 3 (flat terrain).
pub ids: Option<&'a wgpu::BindGroupLayout>,
/// Back faces are culled, as for the game's own terrain.
@@ -1083,6 +1103,9 @@ struct Resources<'a> {
released: std::cell::Cell<(&'static str, u16)>,
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
/// unset, and the device filters `R32Float`).
shadow_linear: u8,
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1207,6 +1230,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::ShadowDepth(n, false) if pack.shadow_linear & 1 << n != 0 => float,
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
@@ -1288,7 +1312,9 @@ fn bindings(
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
return None;
};
let dimension = if i.ty.ends_with("3D") {
let dimension = if i.ty.ends_with("1D") {
wgpu::TextureViewDimension::D1
} else if i.ty.ends_with("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
@@ -1427,7 +1453,7 @@ fn build_pass(
let features = device.features();
// The frame's uniforms, then the pack's custom ones.
let index = |name: &str| {
pack_uniforms::index(name).or_else(|| {
pack_uniforms::index_in(name, geometry.is_some()).or_else(|| {
let k = custom.iter().position(|c| c == name)?;
Some(pack_uniforms::NAMES.len() + k)
})
@@ -1523,11 +1549,12 @@ fn build_pass(
.iter()
.filter(|f| !f.1)
.fold(0u16, |m, f| m | 1 << f.0);
// Gbuffers programs write in place (6.1.6).
// Gbuffers programs write in place (6.1.6). A forced flip of a buffer the program writes
// cancels the write's own flip, as in Iris 1.11.7 (probed with packs of our own).
let flip = if final_pass || geometry.is_some() {
0
} else {
(written & !kept) | mask(&p.flips)
(written & !kept) ^ mask(&p.flips)
};
let created = checked(device, || {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -1616,7 +1643,11 @@ fn build_pass(
.map(|u| u.name.as_str())
.collect();
if !missing.is_empty() {
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
notes.push(format!(
"{}: shader defaults or zero: {}",
p.name,
missing.join(" ")
));
}
let image_copies = image_copies(&samplers, &images);
Some(Pass {
@@ -1626,7 +1657,7 @@ fn build_pass(
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
block: vec![0; interface.uniform_size as usize],
block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -1644,8 +1675,10 @@ fn build_pass(
mipmaps: p.mipmaps,
draw,
atlas: geometry.and_then(|g| g.atlas.clone()),
material_maps: geometry.is_some_and(|g| g.material_maps),
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
portal_groups: HashMap::new(),
copies: image_buffers(&images) & reads,
reads: reads | image_buffers(&images),
buffers: !interface.buffers.is_empty(),
@@ -1753,7 +1786,7 @@ fn build_compute(
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
block: vec![0; interface.uniform_size as usize],
block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -1778,8 +1811,10 @@ fn build_compute(
mipmaps: 0,
draw: false,
atlas: None,
material_maps: false,
ids: false,
groups: HashMap::new(),
portal_groups: HashMap::new(),
buffers: !interface.buffers.is_empty(),
images,
compute: Vec::new(),
@@ -1930,6 +1965,13 @@ impl PackPost {
rgb,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
(0..2)
.filter(|&n| !shadow_config.nearest[n])
.fold(0, |m, n| m | 1 << n)
} else {
0
},
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
@@ -2022,6 +2064,7 @@ impl PackPost {
table: g.table,
depth_write: g.depth_write,
atlas: g.atlas.clone(),
material_maps: g.material_maps,
ids: block_ids.as_ref().map(|i| &i.0),
cull: g.cull,
});
@@ -2049,6 +2092,7 @@ impl PackPost {
table: Some(&geometry.layout),
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
cull: false,
};
@@ -2058,6 +2102,7 @@ impl PackPost {
table: None,
depth_write: false,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
@@ -2075,6 +2120,7 @@ impl PackPost {
post.entities_translucent,
post.particles,
post.damaged_block,
post.beacon_beam,
];
let late = [
false,
@@ -2082,6 +2128,7 @@ impl PackPost {
post.translucent_late,
post.particles_late,
false,
true,
];
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
let shadow_programs = [post.shadow_entities, post.shadow_block];
@@ -2094,6 +2141,7 @@ impl PackPost {
table: Some(&e.table),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
});
@@ -2105,6 +2153,7 @@ impl PackPost {
table: g.table,
depth_write: false,
atlas: g.atlas.clone(),
material_maps: g.material_maps,
ids: g.ids,
cull: false,
};
@@ -2132,6 +2181,7 @@ impl PackPost {
table: Some(layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
});
@@ -2141,16 +2191,20 @@ impl PackPost {
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
});
let hand_geometry = world.hand.then(|| super::pack_hand::PackHand::new(device));
let hand_geometry = world
.hand
.then(|| super::pack_hand::PackHand::new(device, &draws.layout));
let hand = post.hand.zip(hand_geometry).and_then(|(p, geometry)| {
let g = Geometry {
buffers: &entity_buffers,
table: Some(&geometry.layout),
depth_write: true,
atlas: world.celestial.clone(),
material_maps: false,
ids: None,
cull: false,
};
@@ -2175,6 +2229,7 @@ impl PackPost {
table: None,
depth_write: false,
atlas: None,
material_maps: false,
ids: None,
cull: false,
};
@@ -2301,8 +2356,21 @@ impl PackPost {
let depth1 = every_pass()
.flat_map(|p| &p.samplers)
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
let t =
super::pack_shadow::ShadowTargets::new(device, &shadow_config, f, colors, depth1);
let linear = every_pass()
.flat_map(|p| &p.samplers)
.filter(|s| s.filtering)
.fold(0u8, |m, s| match s.source {
Source::ShadowDepth(n, false) => m | 1 << n,
_ => m,
});
let t = super::pack_shadow::ShadowTargets::new(
device,
&shadow_config,
f,
colors,
depth1,
linear,
);
notes.push(format!(
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
t.size,
@@ -2373,7 +2441,13 @@ impl PackPost {
}
});
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut world_target = target(formats[0]);
// The world's colour is floats: an integer colortex0 (`RGBA32UI`...) gets only what the
// pack writes, and is cleared in a pass of its own.
let world_color = holds_floats(formats[0]);
if !world_color {
other_clears.push(0);
}
let mut world_target = world_color.then(|| target(formats[0])).flatten();
if rgb & 1 != 0
&& let Some(t) = &mut world_target
{
@@ -2390,28 +2464,30 @@ impl PackPost {
})
}))
.collect();
let blit = helper(
device,
"vs_full",
if blit_depth {
"fs_blit"
} else {
"fs_blit_color"
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
blit_depth.then_some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
let blit = (world_color || blit_depth || !cleared.is_empty()).then(|| {
helper(
device,
"vs_full",
if blit_depth {
"fs_blit"
} else {
"fs_blit_color"
},
&[
texture_entry(0, float),
texture_entry(1, wgpu::TextureSampleType::Depth),
],
&blit_targets,
// GL's window depth, written for depthtex0 and the terrain's depth test.
blit_depth.then_some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
)
});
let copy = helper(
device,
"vs_uv",
@@ -2486,6 +2562,8 @@ impl PackPost {
depth2,
shadow_targets,
shadow_terrain,
portal_rect: [0.0; 4],
portal_texture: None,
shadow_water,
shadow_comp,
mipped,
@@ -2499,6 +2577,7 @@ impl PackPost {
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
material_maps: world.material_maps,
lightmap: world.lightmap,
deferred_pre: mask(&post.deferred_pre),
composite_pre: mask(&post.composite_pre),
@@ -2710,6 +2789,12 @@ impl PackPost {
shadow_color(i, usize::from(key & 1 << (32 + i) != 0))
.unwrap_or(&self.dummy)
}
Source::ShadowDepth(i, false) if s.filtering => {
match shadow.and_then(|t| t.linear[i].as_ref()) {
Some(v) => v,
None => &self.dummy,
}
}
Source::ShadowDepth(i, _) => match shadow {
Some(t) => match (i, &t.depth1) {
(1, Some(d)) => &d.1,
@@ -2778,8 +2863,16 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Normals => self
.material_maps
.as_ref()
.filter(|_| pass.material_maps)
.map_or(&self.dummy_normal, |maps| &maps[0]),
Source::Specular => self
.material_maps
.as_ref()
.filter(|_| pass.material_maps)
.map_or(&self.dummy_black, |maps| &maps[1]),
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -3090,6 +3183,17 @@ impl PackPost {
continue;
}
let group = self.bind(device, queue, encoder, pass, size);
let portal_group = self.portal_group(
device,
queue,
encoder,
pass,
size,
geometry
.draws
.iter()
.any(|d| d.pack && d.program == program && d.portal),
);
{
let mut rp = self.begin(encoder, pass);
rp.set_pipeline(pass.render());
@@ -3102,7 +3206,14 @@ impl PackPost {
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
let mut on_portal = false;
for d in draws {
if let Some(portal) = &portal_group
&& d.portal != on_portal
{
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
on_portal = d.portal;
}
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
@@ -3114,6 +3225,47 @@ impl PackPost {
self.entities = Some(entities);
}
/// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs that
/// wrap it with their own coordinates sample the portal sprite only. `None` when no portal
/// face draws in this pass.
fn portal_group(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
pass: &mut Pass,
size: [u32; 2],
wanted: bool,
) -> Option<wgpu::BindGroup> {
let atlas = pass.atlas.clone().or_else(|| self.atlas.clone())?;
if !wanted || self.portal_rect[2] <= 0.0 {
return None;
}
if self
.portal_texture
.as_ref()
.is_none_or(|(rect, _)| *rect != self.portal_rect)
{
let view = super::pack_weather::copy_sprite(
device,
encoder,
atlas.texture(),
self.portal_rect,
);
self.portal_texture = Some((self.portal_rect, view));
pass.portal_groups.clear();
}
// `bind` caches groups by a key without the atlas: the portal groups are kept in a cache
// of their own, swapped in with the texture.
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
let saved = std::mem::replace(&mut pass.atlas, view);
let groups = std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
let group = self.bind(device, queue, encoder, pass, size);
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
pass.atlas = saved;
Some(group)
}
/// Entities and block entities into the shadow map, each run with the per-draw uniforms
/// of its gbuffers draw (2.4): opaque, so before the `shadowtex1` copy.
fn draw_shadow_entities(
@@ -3138,6 +3290,15 @@ impl PackPost {
continue;
}
let group = self.bind(device, queue, encoder, pass, size);
let portal_group = self.portal_group(
device,
queue,
encoder,
pass,
size,
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
);
let mut on_portal = false;
let mut rp = self.begin(encoder, pass);
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
@@ -3150,6 +3311,12 @@ impl PackPost {
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
for d in draws {
if let Some(portal) = &portal_group
&& d.portal != on_portal
{
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
on_portal = d.portal;
}
if pass.draw {
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
}
@@ -3176,9 +3343,11 @@ impl PackPost {
entities: &[super::Vertex],
spans: &[super::pack_draw::EntitySpan],
crumbling: [f32; 4],
portal: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
) {
self.portal_rect = portal;
let shadow = self.shadow_casters();
let Some(e) = &mut self.entities else {
rest.clear();
@@ -3300,7 +3469,7 @@ impl PackPost {
size[0] as f32 / size[1].max(1) as f32,
frame.depth_far,
) * unspin;
geometry.prepare(device, queue, frame.hand, projection);
geometry.prepare(device, queue, frame.hand, frame.hand_items, projection);
if geometry.quads > 0 {
let group = self.bind(device, queue, encoder, &mut pass, size);
{
@@ -3313,12 +3482,15 @@ impl PackPost {
rp.set_bind_group(4, g, &[]);
}
rp.set_bind_group(1, &geometry.group, &[]);
if pass.draw {
self.draws.bind(&mut rp, "HAND_SOLID");
}
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
rp.draw_indexed(0..geometry.quads * 6, 0, 0..1);
// The arm and each held item: their own `currentRenderedItemId`.
for &(first, quads, offset) in &geometry.runs {
if pass.draw {
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
}
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
}
}
self.mips_valid &= !pass.written;
}
@@ -3684,6 +3856,10 @@ impl PackPost {
if self.copied & 1 << i != 0 {
attachment |= wgpu::TextureUsages::COPY_SRC;
}
// A buffer that isn't cleared starts each frame with B copied back to A.
if i != 0 && self.clears[i].is_none() {
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
}
let sides = [0, 1]
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
@@ -3848,6 +4024,9 @@ impl PackPost {
TerrainList::ShadowTranslucent,
terrain,
);
if let Some(t) = &self.shadow_targets {
t.copy_linear(encoder);
}
let mut comp = std::mem::take(&mut self.shadow_comp);
for pass in &mut comp {
let mut compute = std::mem::take(&mut pass.compute);
@@ -4069,6 +4248,20 @@ impl PackPost {
&mut self.zeros,
);
let Some(targets) = &self.targets else { return };
// Every frame starts on A. A buffer that isn't cleared and ended the last frame on B has B
// copied back to A, and B keeps what was last written into it: a model that fits Iris 1.11.7's
// values in probe packs of our own (#9009), not a documented Iris rule. colortex0's A is
// overwritten by the world below.
for b in (1..BUFFERS).filter(|&b| self.main & 1 << b != 0 && self.clears[b].is_none()) {
if let Some([a, b_side]) = &targets.color[b] {
encoder.copy_texture_to_texture(
b_side.texture().as_image_copy(),
a.texture().as_image_copy(),
a.texture().size(),
);
}
}
self.main = 0;
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
@@ -4170,14 +4363,15 @@ impl PackPost {
}
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
if self
.blit_group
.as_ref()
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
if let Some(blit) = &self.blit
&& self
.blit_group
.as_ref()
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
{
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack world"),
layout: &self.blit.1,
layout: &blit.1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
@@ -4191,7 +4385,7 @@ impl PackPost {
});
self.blit_group = Some(([world.clone(), world_depth.clone()], group));
}
{
if let Some(blit) = &self.blit {
// The blit leaves a three-channel colortex0's alpha at the 1 it starts with.
let world_ops = if self.rgb & 1 != 0 {
wgpu::Operations {
@@ -4201,10 +4395,11 @@ impl PackPost {
} else {
overwrite
};
let mut attachments = vec![attach(
view(self.main, 0, false).expect("colortex0"),
world_ops,
)];
let mut attachments = vec![if holds_floats(self.formats[0]) {
attach(view(self.main, 0, false).expect("colortex0"), world_ops)
} else {
None
}];
for &b in &self.blit_clears {
attachments.push(attach(
view(self.main, b, false).expect("used buffer"),
@@ -4227,7 +4422,7 @@ impl PackPost {
),
..Default::default()
});
pass.set_pipeline(&self.blit.0);
pass.set_pipeline(&blit.0);
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
pass.draw(0..3, 0..1);
}
@@ -4376,7 +4571,14 @@ impl PackPost {
.as_ref()
.map(|t| &t[main(m, i) ^ usize::from(alt)])
};
if !shown {
// An integer colortex0 has no colour to show: the screen is only cleared.
if !shown && !holds_floats(self.formats[0]) {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack colortex0"),
color_attachments: &[attach(screen, overwrite)],
..Default::default()
});
} else if !shown {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack colortex0"),
layout: &self.copy.1,
@@ -4528,6 +4730,7 @@ mod tests {
}];
let interface = horizon_shaders::Interface {
uniform_size: 48,
initial_values: vec![0; 48],
uniforms: Vec::new(),
samplers: vec![horizon_shaders::Sampler {
name: "colortex0".into(),
@@ -4557,6 +4760,7 @@ mod tests {
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let reads = |group: Group| {
let (_, samplers, ..) = bindings(
@@ -4606,6 +4810,7 @@ mod tests {
};
let interface = horizon_shaders::Interface {
uniform_size: 48,
initial_values: vec![0; 48],
uniforms: Vec::new(),
samplers: Vec::new(),
outputs: vec![0],
@@ -4635,6 +4840,7 @@ mod tests {
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let mut notes = Vec::new();
let (.., images) = bindings(
@@ -4713,6 +4919,170 @@ mod tests {
);
}
/// `blend.<program>` of a composite program blends its output onto what the buffer's side
/// holds (Iris 1.11.7, probed: 0.25 over 0.75 with SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO and
/// a source alpha of 0.25 reads 0.625).
#[test]
fn a_composite_program_blends_onto_its_buffer() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
// A pass's pipeline layout has six bind group slots.
let desc = wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&desc)) else {
return;
};
let dir = std::env::temp_dir().join(format!("hz-composite-blend-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
let file = |name: &str, text: &str| std::fs::write(shaders.join(name), text).unwrap();
file(
"composite.vsh",
"#version 120\nvoid main() { gl_Position = ftransform(); }\n",
);
file(
"composite.fsh",
"#version 120\n/* DRAWBUFFERS:3 */\n\
void main() { gl_FragData[0] = vec4(0.25, 0.25, 1.0, 0.25); }\n",
);
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let properties = horizon_shaders::Properties::parse(
"blend.composite = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
&horizon_shaders::Macros::default(),
);
let mut post = horizon_shaders::load_post(
&pack,
"",
&properties,
&horizon_shaders::Macros::standard(),
&Default::default(),
);
std::fs::remove_dir_all(&dir).unwrap();
let program = post.programs.remove(0);
let buffers = pack_images::Buffers::new(
&device,
Default::default(),
Default::default(),
wgpu::ShaderStages::FRAGMENT,
);
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &[],
images: &[],
left_out: &[],
buffers: &buffers,
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
rgb: 0,
released: std::cell::Cell::new(("", 0)),
stand_ins: &stand_ins,
shadow_linear: 0,
};
let draws = DrawUniforms::new(&device);
let mut notes = Vec::new();
let pass = build_pass(
&device,
program,
&[T::Rgba8Unorm; BUFFERS],
None,
T::Rgba8Unorm,
&mut notes,
None,
&[],
&draws.layout,
&pack,
)
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 64,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: T::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let head = [64.0f32, 1.0, 1.0 / 64.0, 1.0];
queue.write_buffer(&pass.ubo, 0, bytemuck::cast_slice(&head));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pass.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: pass.ubo.as_entire_binding(),
}],
});
let mut encoder = device.create_command_encoder(&Default::default());
let before = wgpu::Color {
r: 0.75,
g: 0.5,
b: 0.25,
a: 0.5,
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(
&view,
wgpu::Operations {
load: wgpu::LoadOp::Clear(before),
store: wgpu::StoreOp::Store,
},
)],
..Default::default()
});
{
// Loaded, as `PackPost::render` attaches a buffer a composite pass writes.
let load = wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
};
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[attach(&view, load)],
..Default::default()
});
rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
rp.draw(0..6, 0..1);
}
let read = 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: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texel: Vec<u8> = read.slice(..).get_mapped_range().unwrap()[..4].to_vec();
// rgb = 0.25 × src + 0.75 × dst, alpha = the source's.
let expected = [0.625, 0.4375, 0.4375, 0.25].map(|v: f32| v * 255.0);
for (got, want) in texel.iter().zip(expected) {
assert!(
(f32::from(*got) - want).abs() <= 1.0,
"{texel:?} vs {expected:?}"
);
}
}
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
@@ -4774,5 +5144,18 @@ mod tests {
#[cfg(test)]
mod destination_tests;
#[cfg(test)]
#[path = "pack_material_tests.rs"]
mod material_tests;
#[cfg(test)]
mod rgb_alpha_tests;
#[cfg(test)]
mod trinary_tests;
#[cfg(test)]
mod const_offset_tests;
#[cfg(test)]
mod integer_colortex0_tests;
@@ -0,0 +1,111 @@
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
use super::*;
/// colortex6 is written three times a frame, the first write leaving the right half alone:
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
#[test]
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
let programs = [
(
"composite",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite1",
format!(
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
fill(0.25)
),
),
(
"composite2",
format!(
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
fill(0.125)
),
),
(
"composite3",
format!(
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
fill(0.5)
),
),
(
"composite4",
format!(
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
fill(0.125)
),
),
(
"composite5",
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
.to_owned(),
),
(
"final",
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
.to_owned(),
),
];
for (program, fragment) in &programs {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
let pack = horizon_shaders::Pack::from_zip_bytes(
"clear-false-history",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..4u8 {
let actual = gpu.render(&mut post, [16, 16]);
let sum = 32 * (frame + 1);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(n, p)| {
let expected = match (n % 16 < 8, frame) {
(true, _) => [128, 128, sum, 255],
(false, 0) => [0, 0, sum, 255],
(false, _) => [32, 0, sum, 255],
};
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,70 @@
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// Each texel of colortex1 holds its own column and row, in eighths.
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 1 */\n\
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
);
add("final.vsh", VERTEX);
// Red and green: what the texel two columns right and one row down holds; blue: this row.
add(
"final.fsh",
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-const-offset",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
post
}
#[test]
fn a_const_local_offset_reads_the_offset_texel() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
let mut checked = 0;
for (n, p) in pixels.iter().enumerate() {
let x = (n % 8) as u8;
// The bottom row (GL's row 0) and the two right columns read past the edge.
if x >= 6 || p[2] == 0 {
continue;
}
let row = (0..8)
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
.expect("row in blue");
let want = [eighth(x + 2), eighth(row - 1)];
assert!(
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
);
checked += 1;
}
assert_eq!(checked, 6 * 7);
}
@@ -315,19 +315,21 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
let inputs=horizon_shaders::GameInputs {
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
};
let mut post = gpu.load(
shaders(sampler, stage, scene),
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: terrain.then_some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
material_maps: false,
ids: None,
cull: false,
}),
@@ -617,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -635,3 +638,143 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
post_effects: crate::post_chain::Request::none(),
}
}
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
/// both stage files.
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
let gpu = Gpu::new()?;
if !gpu
.device
.features()
.contains(wgpu::Features::FLOAT32_FILTERABLE)
{
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
return None;
}
let color = gpu.texture(T::Rgba8Unorm);
let depth = gpu.texture(T::Depth32Float);
gpu.clear(&color, &depth, 0.0);
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
item_id_declared: false,
}),
..Default::default()
};
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
let files = vec![
("final.vsh".to_string(), VERTEX.to_string()),
(
"final.fsh".into(),
format!(
"#version 330 compatibility\n{settings}\
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
float moved(sampler2D s, ivec2 t) {{\n\
float a = texelFetch(s, t, 0).r;\n\
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
}}\n\
void main() {{\n\
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
}}\n"
),
),
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
(
"shadow.vsh".into(),
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
),
(
"shadow.fsh".into(),
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
),
];
let mut post = gpu.load(
files,
World {
atlas: None,
material_maps: None,
lightmap: color.clone(),
terrain: Some(Geometry {
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: false,
material_maps: false,
}),
block_ids: &[],
celestial: None,
gui_size: SIZE,
clouds: None,
weather: false,
hand: false,
},
inputs,
);
assert!(post.has_shadow(), "{:?}", post.notes);
let positions: [[f32; 3]; 6] = [
[-1., -1., 0.],
[1., -1., 0.],
[1., 1., 0.],
[-1., -1., 0.],
[1., 1., 0.],
[-1., 1., 0.],
];
let vertices = gpu
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Original shadow depth ramp"),
contents: bytemuck::cast_slice(&positions),
usage: wgpu::BufferUsages::VERTEX,
});
let hud = HudBatch::new(SIZE);
let frame = fixture_frame(&hud);
Some(
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
if list == TerrainList::ShadowSolid {
rp.set_vertex_buffer(0, vertices.slice(..));
rp.draw(0..6, 0..1);
}
}),
)
}
#[test]
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
if let Some(pixels) = shadow_depth_filtering("") {
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
}
}
#[test]
fn shadowtex_nearest_settings_read_the_nearest_texel() {
for (line, expected) in [
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
] {
if let Some(pixels) = shadow_depth_filtering(line) {
all_pixels(&pixels, expected, line);
}
}
}
@@ -208,6 +208,7 @@ impl Fixture {
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap,
terrain: None,
block_ids: &[],
@@ -346,8 +347,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
let Some(gpu) = Fixture::new() else { return };
for clear in [true, false] {
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
// Every frame changes which physical image is main. The other image is fully
// seeded red before the partial write, independent of its initial clearing.
// The other image is fully seeded red before the partial write, independent of its
// initial clearing.
for frame in 0..3 {
let actual = gpu.render(&mut post, [16, 16]);
assert!(
@@ -433,6 +434,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -521,3 +523,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
#[path = "clear_false_tests.rs"]
mod clear_false_tests;
@@ -0,0 +1,73 @@
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
//! are Iris 1.11.7's, measured with probe packs of our own.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
#[test]
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
let Some(gpu) = Fixture::new() else { return };
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
for (program, fragment) in [
(
"composite",
"#version 120\n/* RENDERTARGETS:2,4 */\n\
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
),
(
"composite1",
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
),
(
"composite2",
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
),
(
"final",
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
),
] {
add(&format!("{program}.vsh"), VERTEX);
add(&format!("{program}.fsh"), fragment);
}
add(
"shaders.properties",
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"forced-flip-of-a-written-buffer",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.build(post);
for frame in 0..2 {
let actual = gpu.render(&mut post, [16, 16]);
let wrong: Vec<_> = (actual.iter().enumerate())
.filter(|(_, p)| {
p.iter()
.zip([191, 0, 0, 255])
.any(|(a, b)| a.abs_diff(b) > 1)
})
.map(|(n, p)| (n, *p))
.take(4)
.collect();
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
}
}
@@ -0,0 +1,85 @@
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
//! sampled from it.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
fn pack(depth: bool, last: bool) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("composite.vsh", VERTEX);
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
let value = if depth {
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
} else {
"uvec4(0u, 200u, 0u, 255u)"
};
add(
"composite.fsh",
&format!(
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
),
);
if last {
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
);
}
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-integer-colortex0",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(
post.programs.len(),
1 + usize::from(last),
"{:?}",
post.notes
);
post
}
#[test]
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
let Some(gpu) = Fixture::new() else { return };
for depth in [false, true] {
let mut post = gpu.build(pack(depth, true));
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
for frame in 0..2 {
let pixels = gpu.render(&mut post, [8, 8]);
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
}
}
}
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
/// then was not measured. The frame must still run.
#[test]
fn an_integer_colortex0_without_final_still_runs() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack(false, false));
for _ in 0..2 {
gpu.render(&mut post, [8, 8]);
}
}
@@ -0,0 +1,78 @@
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
//! and median of three values, for float, int and uint vectors (the extension's additions to
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
fn pack() -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add("final.vsh", VERTEX);
// Pixel column x shows case x % 8 in red, green and blue.
add(
"final.fsh",
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
vec3 o = vec3(0.0);\n\
switch (x) {\n\
case 0: o = min3(a, b, c); break;\n\
case 1: o = max3(a, b, c); break;\n\
case 2: o = mid3(a, b, c); break;\n\
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-trinary",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let post = horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
post
}
#[test]
fn trinary_min_max_and_median_per_component() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack());
let pixels = gpu.render(&mut post, [8, 8]);
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
let tenths: [[u8; 3]; 8] = [
[1, 2, 0],
[6, 9, 8],
[3, 5, 4],
[0, 4, 2],
[6, 8, 7],
[3, 5, 0],
[4, 5, 3],
[6, 2, 0],
];
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
assert!(
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
"case {case}: {got:?}, expected {want:?}; row {row:?}"
);
}
}
@@ -215,6 +215,7 @@ fn pack(
T::Rgba8Unorm,
World {
atlas: None,
material_maps: None,
lightmap: lightmap.clone(),
terrain: None,
block_ids: &[],
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -35,6 +35,13 @@ pub(super) struct ShadowTargets {
pub depth: (wgpu::Texture, wgpu::TextureView),
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
pub linear: [Option<wgpu::TextureView>; 2],
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
/// and their stride in bytes.
staging: Option<(wgpu::Buffer, u32)>,
/// Both sides of each shadowcolor buffer used.
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
/// The clear colour of each buffer cleared every frame.
@@ -77,13 +84,15 @@ fn target(
impl ShadowTargets {
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
/// sampler). The resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
depth1: bool,
linear: u8,
) -> Self {
let size = shadow
.resolution
@@ -105,10 +114,43 @@ impl ShadowTargets {
})
})
});
let linear = [0, 1].map(|n| {
(linear & 1 << n != 0).then(|| {
let label = format!("Shader pack shadowtex{n} (filtered)");
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
.create_view(&Default::default())
})
});
let staging = linear.iter().any(Option::is_some).then(|| {
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack shadow depth rows"),
size: u64::from(stride) * u64::from(size),
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
(buffer, stride)
});
Self {
size,
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
linear,
staging,
color,
clears: std::array::from_fn(|n| {
let b = &shadow.colors[n];
@@ -214,4 +256,38 @@ impl ShadowTargets {
);
}
}
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
/// copy before translucent geometry).
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
let Some((buffer, stride)) = &self.staging else {
return;
};
for (n, linear) in self.linear.iter().enumerate() {
let Some(linear) = linear else {
continue;
};
let depth = match (n, &self.depth1) {
(1, Some(d)) => &d.0,
_ => &self.depth.0,
};
let rows = wgpu::TexelCopyBufferInfo {
buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(*stride),
rows_per_image: None,
},
};
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
aspect: wgpu::TextureAspect::DepthOnly,
..depth.as_image_copy()
},
rows,
depth.size(),
);
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
}
}
}
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
],
after: " gl_Position.z = gl_Position.w;\n".into(),
alpha_test: None,
item_id_declared: false,
}
}
@@ -265,6 +265,13 @@ pub const NAMES: &[&str] = &[
"playerMood",
"constantMood",
"centerDepthSmooth",
"alphaTestRef",
"entityId",
"blockEntityId",
"entityColor",
GBUFFERS_FOG_MODE,
GBUFFERS_FOG_SHAPE,
GBUFFERS_CENTER_DEPTH,
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -277,6 +284,13 @@ pub const NAMES: &[&str] = &[
"cloudHeight",
];
/// What programs that draw the game's geometry (gbuffers, shadow) read as `fogMode`,
/// `fogShape` and `centerDepthSmooth`: no GLSL name can hold a dot, so no pack reads these
/// directly.
const GBUFFERS_FOG_MODE: &str = "gbuffers.fogMode";
const GBUFFERS_FOG_SHAPE: &str = "gbuffers.fogShape";
const GBUFFERS_CENTER_DEPTH: &str = "gbuffers.centerDepthSmooth";
/// Values carried from frame to frame.
pub struct FrameUniforms {
pub values: Vec<Value>,
@@ -472,18 +486,16 @@ impl FrameUniforms {
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
// Vanilla fades by the stronger of two linear fogs; a pack gets the environmental one
// (`FogData::environmental_start`/`environmental_end`), as Iris 1.11.7 gives it in every
// program kind: 0 to 1024 on a clear day, -160 to 768 in rain, -8 to 96 under water, 10
// to 96 in the Nether (measured with a probe pack).
let (fog_color, fog_start, fog_end) = match &frame.fog {
Some(f) => {
let (start, end) = if f.environmental_end < f.render_distance_end {
(f.environmental_start, f.environmental_end)
} else {
(f.render_distance_start, f.render_distance_end)
};
([f.color[0], f.color[1], f.color[2]], start, end)
}
Some(f) => (
[f.color[0], f.color[1], f.color[2]],
f.environmental_start,
f.environmental_end,
),
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
};
let light = shadow_light(frame, &self.shadow);
@@ -545,12 +557,22 @@ impl FrameUniforms {
"thunderStrength" => Value::f(&[inputs.thunder]),
"fogColor" => Value::f(&fog_color),
"skyColor" => Value::f(&frame.sky.sky_color),
// GL_LINEAR: vanilla's fog is linear.
"fogMode" => Value::i(&[9729]),
// Iris 1.11.7, measured: programs that draw no geometry read no fog mode (0) and
// shape (-1); those that do read GL_LINEAR and a cylinder (1). The density is 0.
"fogMode" => Value::i(&[0]),
GBUFFERS_FOG_MODE => Value::i(&[9729]),
"fogStart" => Value::f(&[fog_start]),
"fogEnd" => Value::f(&[fog_end]),
"fogDensity" => Value::f(&[1.0]),
"fogShape" => Value::i(&[0]),
"fogDensity" => Value::f(&[0.0]),
"fogShape" => Value::i(&[-1]),
GBUFFERS_FOG_SHAPE => Value::i(&[1]),
// Iris 1.11.7 gives gbuffers programs 0 (measured in 16 scenes).
GBUFFERS_CENTER_DEPTH => Value::f(&[0.0]),
// Outside a draw (gbuffers programs get these per draw), as Iris 1.11.7 gives
// them to composite and final programs (measured).
"alphaTestRef" => Value::f(&[0.1]),
"blockEntityId" => Value::i(&[-1]),
"entityColor" => Value::f(&[0.0; 4]),
"isEyeInWater" => Value::i(&[inputs.eye_in]),
"blindness" => Value::f(&[inputs.blindness]),
"nightVision" => Value::f(&[frame.night_vision]),
@@ -690,6 +712,7 @@ fn components(name: &str) -> usize {
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -700,6 +723,18 @@ pub fn index(name: &str) -> Option<usize> {
NAMES.iter().position(|n| *n == name)
}
/// The position in [`NAMES`] of the value a program reads as `name`; `geometry`: the program
/// draws the game's geometry (gbuffers, shadow), which reads its own fog mode and shape and no
/// centre depth.
pub fn index_in(name: &str, geometry: bool) -> Option<usize> {
match name {
"fogMode" if geometry => index(GBUFFERS_FOG_MODE),
"fogShape" if geometry => index(GBUFFERS_FOG_SHAPE),
"centerDepthSmooth" if geometry => index(GBUFFERS_CENTER_DEPTH),
_ => index(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1178,6 +1213,64 @@ mod initial_history_tests {
}
}
fn int(u: &FrameUniforms, at: usize) -> i32 {
match u.values[at] {
Value::I(v) => v[0],
_ => panic!("integer uniform expected"),
}
}
/// Iris 1.11.7, read back exactly with a probe pack on a clear day (render distance 12,
/// cloud range 64): fogStart 0 and fogEnd 1024 (the environmental fog, not the render
/// distance's 172.8 to 192), fogDensity 0; composite and final programs read alphaTestRef
/// 0.1, blockEntityId -1, fogMode 0 and fogShape -1.
#[test]
fn fog_and_draw_uniforms_outside_a_draw_follow_iris() {
let hud = HudBatch::new([800, 450]);
let mut f = frame(&hud);
f.fog = Some(crate::fog::FogData {
color: [0.7, 0.8, 1.0, 1.0],
environmental_start: 0.0,
environmental_end: 1024.0,
render_distance_start: 172.8,
render_distance_end: 192.0,
sky_end: 192.0,
cloud_end: 1024.0,
});
let mut u = FrameUniforms::default();
u.update(&f, &PackInputs::default(), [800, 450]);
assert_eq!(float(&u, "fogStart")[0], 0.0);
assert_eq!(float(&u, "fogEnd")[0], 1024.0);
assert_eq!(float(&u, "fogDensity")[0], 0.0);
assert_eq!(float(&u, "alphaTestRef")[0], 0.1);
assert_eq!(int(&u, index("blockEntityId").unwrap()), -1);
assert_eq!(int(&u, index("entityId").unwrap()), 0);
assert_eq!(int(&u, index("fogMode").unwrap()), 0);
assert_eq!(int(&u, index("fogShape").unwrap()), -1);
}
/// Iris 1.11.7 (probe pack): gbuffers programs read fogMode GL_LINEAR (9729) and fogShape 1
/// (cylinder) where composite and final programs read 0 and -1.
#[test]
fn programs_that_draw_geometry_read_their_own_fog_and_centre_depth() {
let hud = HudBatch::new([800, 450]);
let mut u = FrameUniforms::default();
u.update(&frame(&hud), &PackInputs::default(), [800, 450]);
let at = |name, geometry| index_in(name, geometry).unwrap();
assert_eq!(int(&u, at("fogMode", true)), 9729);
assert_eq!(int(&u, at("fogShape", true)), 1);
assert_eq!(int(&u, at("fogMode", false)), 0);
assert_eq!(int(&u, at("fogShape", false)), -1);
assert_eq!(at("fogStart", true), at("fogStart", false));
// Iris 1.11.7 (probe pack): `centerDepthSmooth` reads 0 in gbuffers programs.
let depth = |geometry| match u.values[at("centerDepthSmooth", geometry)] {
Value::F(v) => v[0],
_ => panic!("float uniform expected"),
};
assert_eq!(depth(true), 0.0);
assert_eq!(depth(false), 1.0);
}
/// Iris 1.11.7, read frame by frame with a probe pack: eyeBrightnessSmooth falls by half
/// in 1 s and is rounded down; wetness takes 60 s to rise halfway and 20 s to fall; the
/// centre depth 0.1 s.
@@ -1295,6 +1388,7 @@ mod initial_history_tests {
celestial: &[],
stars: None,
hand: &[],
hand_items: &[],
hand_fov_degrees: 70.0,
screen: &[],
pack: Default::default(),
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
op: ">",
reference: 0.1,
}),
item_id_declared: false,
}
}
@@ -162,7 +163,7 @@ impl PackWeather {
/// A texture holding the atlas's texels under `rect` (normalised x, y, width, height), as
/// Horizon's weather shader reads them (`weather_texel`).
fn copy_sprite(
pub(super) fn copy_sprite(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
atlas: &wgpu::Texture,
+118 -16
View File
@@ -1,7 +1,8 @@
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
//! then drawn onto the swapchain image as a fullscreen triangle.
//! then drawn onto the swapchain image as a fullscreen triangle. A 16-bit frame
//! (`Renderer::frame_format`) with HDR output off goes the same way every frame.
/// The texture a frame to read back is drawn into, and the pass that puts it
/// on the swapchain afterwards.
@@ -15,18 +16,21 @@ pub struct Readback {
impl Readback {
/// The texture this frame draws into instead of the swapchain image
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
/// image cannot be copied from.
/// `surface`, in the frame's `format`: `None` unless that differs from the
/// swapchain's, or the frame is read back (`screenshot`) and the image
/// cannot be copied from.
pub fn target(
&mut self,
device: &wgpu::Device,
surface: &wgpu::Texture,
format: wgpu::TextureFormat,
screenshot: bool,
) -> Option<wgpu::Texture> {
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
if format == surface.format().remove_srgb_suffix()
&& (!screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC))
{
return None;
}
let format = surface.format().remove_srgb_suffix();
if self
.texture
.as_ref()
@@ -60,22 +64,57 @@ impl Readback {
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = &self.texture else {
let Some(texture) = self.texture.clone() else {
return;
};
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let target = surface.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
let source = texture.create_view(&Default::default());
self.blit(device, encoder, &texture, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
/// as an 8-bit frame holds it, for a screenshot to read back.
pub fn unorm_copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
format: wgpu::TextureFormat,
) -> wgpu::Texture {
let copy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot copy"),
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let target = copy.create_view(&Default::default());
self.blit(device, encoder, frame, &target, format);
copy
}
fn blit(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::Texture,
target: &wgpu::TextureView,
format: wgpu::TextureFormat,
) {
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let source = source.create_view(&Default::default());
if let Some((_, blitter)) = &self.blitter {
blitter.copy(device, encoder, &source, &target);
blitter.copy(device, encoder, &source, target);
}
}
}
@@ -84,6 +123,8 @@ impl Readback {
mod tests {
use super::*;
const F: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
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()?;
@@ -101,7 +142,7 @@ mod tests {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
format: F,
usage,
view_formats: &[],
})
@@ -156,7 +197,7 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
let blitter = wgpu::util::TextureBlitter::new(&device, F);
let mut readback = Readback::default();
let colors = [
(1.0, [0, 0, 255, 255]),
@@ -173,7 +214,7 @@ mod tests {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, true)
.target(&device, &surface, F, true)
.unwrap_or_else(|| surface.clone());
clear(&mut encoder, &frame, color);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
@@ -205,9 +246,70 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
assert!(readback.target(&device, &readable, true).is_none());
assert!(readback.target(&device, &readable, F, true).is_none());
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
assert!(readback.target(&device, &surface, false).is_none());
assert!(readback.target(&device, &surface, F, false).is_none());
assert!(readback.texture.is_none() && !readback.drawn);
}
/// A 16-bit frame with HDR output off draws into a 16-bit texture of its own, and both
/// the swapchain and a screenshot's 8-bit copy get it clamped as an 8-bit frame holds it.
fn reaches_an_8_bit_swapchain(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
color: wgpu::Color,
expected: [u8; 4],
) {
let surface = texture(
device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut readback = Readback::default();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback.target(device, &surface, format, false).unwrap();
assert_eq!(frame.format(), format);
clear(&mut encoder, &frame, color);
let copy = readback.unorm_copy(device, &mut encoder, &frame, F);
let screenshot = crate::gfx::copy_frame(device, &mut encoder, &copy, [70, 3]);
readback.present(device, &mut encoder, &surface);
let presented = crate::gfx::copy_frame(device, &mut encoder, &surface, [70, 3]);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{error}");
}
assert_eq!(last_pixel(device, &screenshot), expected);
assert_eq!(last_pixel(device, &presented), expected);
}
/// `Rgba16Unorm`, the frame HDR output draws into where the device can
/// (`Renderer::frame_format`).
#[test]
fn a_16_bit_unorm_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let wide = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
if !adapter.features().contains(wide) {
return;
}
let descriptor = wgpu::DeviceDescriptor {
required_features: wide,
..Default::default()
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&descriptor)) else {
return;
};
let color = wgpu::Color {
r: 1.0,
g: 0.2,
b: 0.0,
a: 1.0,
};
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
}
@@ -0,0 +1,417 @@
//! A visibility buffer for solid terrain, opt-in with `HORIZON_VISBUFFER=1` (read once, at
//! startup): pass 1 rasterises the solid flat draws (near sections and far tiles, phase 1 and,
//! with occlusion culling, phase 2) writing only depth and the ID of the quad each pixel shows
//! (`visbuffer.wgsl`'s `vis_id`: arena page and quad); cutout and fading
//! quads keep their alpha test and dither there, the rest never discard. Pass 2 is one
//! full-screen draw with up to `PAGE_SLOTS` arena pages bound at once (more pages, more draws)
//! that reads the quad back from its page and its section's origin (through a copy of every
//! drawn page's block table in one buffer), and runs terrain.wgsl's
//! vertex work and solid shading once per pixel with analytic screen gradients. Every solid
//! pixel is shaded once, however many quads were drawn over it and however small they are.
//! A draw per page instead made each GPU thread group run the shading once for every page
//! among its pixels, with most lanes idle: the pages' sections lie all over the screen.
//!
//! It runs only in frames drawn flat (`flat`: Metal past `flat::MIN_SECTIONS`, or
//! `HORIZON_FLAT_DRAWS=1`) and falls back to the normal solid passes in frames with a shader
//! pack drawing terrain, one of Horizon's own packs with a material stage or a shadow pass, a
//! pack's surface target, and on the title screen. The water mirror's own pass stays as it is.
use super::arena::Arena;
use super::flat::Flat;
use super::pack_timing::PassTiming;
/// Arena pages one resolve draw reads (`visbuffer.wgsl`'s `vis_page_0`...).
pub const PAGE_SLOTS: u32 = 16;
/// Arena pages an ID names (`visbuffer.wgsl`'s `vis_id`): frames with more draw without it.
pub const MAX_PAGES: u32 = 64;
/// The resolve binds that many pages, the block tables and the origins by section id.
const STORAGE_BUFFERS: u32 = PAGE_SLOTS + 2;
const IDS: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
/// Whether the visibility buffer is asked for, and the device binds storage buffers enough.
pub fn wanted(device: &wgpu::Device) -> bool {
std::env::var("HORIZON_VISBUFFER").as_deref() == Ok("1")
&& device.limits().max_storage_buffers_per_shader_stage >= STORAGE_BUFFERS
}
pub struct Visbuffer {
/// Pass 1 over the quads `fs_opaque` draws (no discard) and the rest.
opaque: wgpu::RenderPipeline,
cutout: wgpu::RenderPipeline,
resolve: wgpu::RenderPipeline,
/// Pass 2's group 1: the block tables (binding 0), the origins by section id (1),
/// `PAGE_SLOTS` pages' vertices (from 2), the IDs (after them).
page_layout: wgpu::BindGroupLayout,
/// Every page's block table, page after page (`Flat::blocks_per_page` entries each),
/// copied each frame from the drawn pages' own, and its size in pages.
blocks: Option<(wgpu::Buffer, u32)>,
blocks_per_page: u64,
/// The IDs, the size of the world's depth.
ids: Option<(wgpu::TextureView, [u32; 2])>,
}
impl Visbuffer {
/// `terrain_layout`: terrain.wgsl's group 0; `flat_layout`: the flat draws' group 1;
/// `color`: the world pass's colour format; `constants`: the sprite arrays' pipeline
/// constants.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
flat: &Flat,
color: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> Self {
let flat_layout = &flat.draw_layout;
let blocks_per_page = flat.blocks_per_page();
let resolve_constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
.collect();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon visibility buffer"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"))
.into(),
),
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries: Vec<_> = (0..2 + PAGE_SLOTS)
.map(storage)
.chain([wgpu::BindGroupLayoutEntry {
binding: 2 + PAGE_SLOTS,
visibility: fragment,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Uint,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}])
.collect();
let page_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Visibility buffer pages"),
entries: &entries,
});
let id_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Visibility buffer IDs"),
bind_group_layouts: &[Some(terrain_layout), Some(flat_layout)],
..Default::default()
});
let resolve_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Visibility buffer resolve"),
bind_group_layouts: &[Some(terrain_layout), Some(&page_layout)],
..Default::default()
});
let ids = |label: &'static str, vertex: &'static str, fragment: &'static str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&id_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: Default::default(),
// `section_corner` reads the arena page (`flat::ARENA_BINDING`).
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: IDS,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let opaque = ids("Visibility buffer opaque", "vs_vis_opaque", "fs_vis_id");
let cutout = ids("Visibility buffer cutout", "vs_vis_cutout", "fs_vis_cutout");
let resolve = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Visibility buffer resolve"),
layout: Some(&resolve_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_vis_resolve"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_vis_resolve"),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &resolve_constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Self {
opaque,
cutout,
resolve,
page_layout,
blocks: None,
blocks_per_page,
ids: None,
}
}
/// Makes the IDs the world depth's `size`, and the block tables hold the pages `flat`
/// draws this frame.
pub fn fit(&mut self, device: &wgpu::Device, size: [u32; 2], flat: &Flat) {
let pages = flat.calls[..flat.solid_calls]
.iter()
.map(|c| c.page + 1)
.max()
.unwrap_or(1);
if self.blocks.as_ref().is_none_or(|(_, n)| *n < pages) {
let pages = pages.next_power_of_two();
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Visibility buffer block tables"),
size: u64::from(pages) * self.blocks_per_page * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.blocks = Some((buffer, pages));
}
if self.ids.as_ref().is_some_and(|(_, s)| *s == size) {
return;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Visibility buffer IDs"),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: IDS,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
self.ids = Some((texture.create_view(&Default::default()), size));
}
/// Pass 1's render pass over the world's `depth`: the IDs cleared (`clear`, phase 1) or
/// kept (phase 2 of occlusion culling).
pub fn id_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
clear: bool,
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"
};
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer"),
timestamp_writes: timing.and_then(|t| t.writes(name)),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ids,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: if clear {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
wgpu::LoadOp::Load
},
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
/// pipeline over `calls[..pass_calls[0]]`, the cutout one over
/// `calls[pass_calls[1]..phase1_calls]` (each leaves out the other's quads).
pub fn draw_ids<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: &Flat,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy,
) {
let [opaque, cutout, _] = flat.pass_calls;
pass.set_pipeline(&self.opaque);
flat.draw_paged(pass, &flat.calls[..opaque], vertices);
pass.set_pipeline(&self.cutout);
flat.draw_paged(pass, &flat.calls[cutout..flat.phase1_calls], vertices);
}
/// Phase 2's calls as `Flat::flatten_phase2` left them (their first instance is their
/// page: `Flat::page_instances`), through both pipelines.
pub fn draw_ids_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: &Flat,
vertices: impl Fn(u32) -> wgpu::BufferSlice<'a> + Copy,
) {
// Phase 2 lists each section's opaque runs first, its cutout runs after.
let split = flat.pass_calls[2] - flat.phase1_calls;
let total = flat.solid_calls - flat.phase1_calls;
pass.set_pipeline(&self.opaque);
flat.draw_phase2(pass, 0..split, vertices);
pass.set_pipeline(&self.cutout);
flat.draw_phase2(pass, split..total, vertices);
}
/// Bytes of its block tables and IDs.
pub fn bytes(&self) -> u64 {
let blocks = self.blocks.as_ref().map_or(0, |(b, _)| b.size());
let ids = self
.ids
.as_ref()
.map_or(0, |(_, [w, h])| u64::from(*w) * u64::from(*h) * 4);
blocks + ids
}
/// Pass 2: shades the solid terrain the IDs show into `target`, the world's colour,
/// over the sky drawn before. `terrain`: terrain.wgsl's group 0.
#[allow(clippy::too_many_arguments)]
pub fn resolve(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
terrain: &wgpu::BindGroup,
flat: &Flat,
arena: &Arena,
timing: Option<&mut PassTiming>,
) {
let (Some((ids, _)), Some((blocks, _))) = (&self.ids, &self.blocks) else {
return;
};
let mut pages: Vec<u32> = flat.calls[..flat.solid_calls]
.iter()
.map(|c| c.page)
.collect();
pages.sort_unstable();
pages.dedup();
let Some(&any) = pages.first() else {
return;
};
let table = self.blocks_per_page * 4;
for &page in &pages {
if let Some(from) = flat.block_table(page) {
encoder.copy_buffer_to_buffer(from, 0, blocks, u64::from(page) * table, table);
}
}
// Pages in groups of `PAGE_SLOTS` by number: a page's slot is its number's remainder.
// Slots no drawn page fills take one that is, which no pixel's ID names.
let mut chunks: Vec<u32> = pages.iter().map(|p| p / PAGE_SLOTS).collect();
chunks.dedup();
let groups: Vec<(u32, wgpu::BindGroup)> = chunks
.into_iter()
.map(|chunk| {
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: blocks.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: flat.origins().as_entire_binding(),
},
];
entries.extend((0..PAGE_SLOTS).map(|slot| {
let page = chunk * PAGE_SLOTS + slot;
let page = if pages.binary_search(&page).is_ok() {
page
} else {
any
};
wgpu::BindGroupEntry {
binding: 2 + slot,
resource: arena.buffer(page).as_entire_binding(),
}
}));
entries.push(wgpu::BindGroupEntry {
binding: 2 + PAGE_SLOTS,
resource: wgpu::BindingResource::TextureView(ids),
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Visibility buffer pages"),
layout: &self.page_layout,
entries: &entries,
});
(chunk, group)
})
.collect();
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon visibility buffer resolve"),
timestamp_writes: timing.and_then(|t| t.writes("Visibility resolve")),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
..Default::default()
});
pass.set_pipeline(&self.resolve);
pass.set_bind_group(0, terrain, &[]);
// The group of pages goes as the instance: each draw shades the pixels whose quad is
// in one of its pages (one draw, up to `PAGE_SLOTS` pages).
for (chunk, group) in &groups {
pass.set_bind_group(1, group, &[]);
pass.draw(0..3, *chunk..*chunk + 1);
}
}
}
+95 -2
View File
@@ -128,6 +128,44 @@ impl EntityCullingMode {
}
}
/// Particle visibility strategy, independent of Entity Culling.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ParticleCullingMode {
#[default]
Off,
Synchronous,
Asynchronous,
}
impl ParticleCullingMode {
pub const ALL: [ParticleCullingMode; 3] = [
ParticleCullingMode::Off,
ParticleCullingMode::Synchronous,
ParticleCullingMode::Asynchronous,
];
pub fn name(self) -> &'static str {
match self {
Self::Off => "off",
Self::Synchronous => "sync",
Self::Asynchronous => "async",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Synchronous => "Synchronous",
Self::Asynchronous => "Asynchronous",
}
}
fn parse(s: &str) -> Self {
match s {
"synchronous" | "sync" => Self::Synchronous,
"asynchronous" | "async" => Self::Asynchronous,
"off" => Self::Off,
_ => Self::default(),
}
}
}
/// How the swapchain presents frames.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentMode {
@@ -333,6 +371,13 @@ pub enum LeafCullingMode {
/// Every leaf face, as vanilla's fancy leaves draw them.
#[default]
Off,
/// Drops a face between two leaves when the leaf it faces has a leaf or
/// a full opaque cube behind it too: that face only shows through two
/// cutout layers. The faces behind the outer layer stay, so a crown
/// looks the same from outside. The faced leaf must be in the section
/// (as `Hollow`), so the block behind it is at most a border cell of the
/// next section, whose edits remesh this one.
Hidden,
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
/// in the opaque layer, so their cutouts no longer show the layers behind.
Solid,
@@ -344,8 +389,9 @@ pub enum LeafCullingMode {
Hollow,
}
impl LeafCullingMode {
pub const ALL: [LeafCullingMode; 4] = [
pub const ALL: [LeafCullingMode; 5] = [
LeafCullingMode::Off,
LeafCullingMode::Hidden,
LeafCullingMode::Solid,
LeafCullingMode::SolidAggressive,
LeafCullingMode::Hollow,
@@ -353,6 +399,7 @@ impl LeafCullingMode {
pub fn name(self) -> &'static str {
match self {
LeafCullingMode::Off => "off",
LeafCullingMode::Hidden => "hidden",
LeafCullingMode::Solid => "solid",
LeafCullingMode::SolidAggressive => "solid_aggressive",
LeafCullingMode::Hollow => "hollow",
@@ -361,6 +408,7 @@ impl LeafCullingMode {
pub fn label(self) -> &'static str {
match self {
LeafCullingMode::Off => "Off",
LeafCullingMode::Hidden => "Hidden Faces",
LeafCullingMode::Solid => "Solid",
LeafCullingMode::SolidAggressive => "Solid Aggressive",
LeafCullingMode::Hollow => "Hollow",
@@ -473,6 +521,17 @@ pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static SMOOTH_GRADIENTS_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can draw, blend and filter the 16-bit frame (`Graphics::hdr_smooth_gradients`):
/// set when the renderer starts. Where it can't, the settings grey its switch out.
pub fn offer_smooth_gradients(on: bool) {
SMOOTH_GRADIENTS_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -551,6 +610,8 @@ pub struct Graphics {
pub cave_culling: bool,
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
pub entity_culling: EntityCullingMode,
/// Skip particles outside the view or hidden behind opaque terrain.
pub particle_culling: ParticleCullingMode,
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
@@ -634,6 +695,11 @@ pub struct Graphics {
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// Smooth Gradients (HDR page, on by default): with HDR output on, the frame is drawn in
/// 16 bits, so dark gradients don't band (`Renderer::frame_format`). Read when a renderer
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -648,6 +714,7 @@ impl Default for Graphics {
blue_map: crate::bluemap::Settings::default(),
cave_culling: true,
entity_culling: EntityCullingMode::Off,
particle_culling: ParticleCullingMode::Off,
leaf_culling: LeafCullingMode::Off,
cache_shulker_boxes: false,
// Sign text has used its renderer cache independently of the new model caches.
@@ -685,6 +752,7 @@ impl Default for Graphics {
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: true,
hdr_smooth_gradients: true,
shader_pack: String::new(),
}
}
@@ -741,6 +809,7 @@ impl Graphics {
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
"blockEntityCache" => {
let enabled = flag(false);
@@ -815,6 +884,7 @@ impl Graphics {
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"hdrSmoothGradients" => g.hdr_smooth_gradients = flag(g.hdr_smooth_gradients),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -831,12 +901,13 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
self.entity_culling.name(),
self.particle_culling.name(),
self.leaf_culling.name(),
bool_text(self.cache_shulker_boxes),
bool_text(self.cache_signs),
@@ -881,6 +952,7 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
);
text += &self.blue_map.serialize();
text
@@ -896,6 +968,11 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
/// HDR on, with Smooth Gradients.
pub fn wide_frame(&self) -> bool {
self.hdr && self.hdr_smooth_gradients
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
@@ -1015,6 +1092,7 @@ mod tests {
},
cave_culling: false,
entity_culling: EntityCullingMode::Synchronous,
particle_culling: ParticleCullingMode::Asynchronous,
leaf_culling: LeafCullingMode::SolidAggressive,
cache_shulker_boxes: true,
cache_signs: true,
@@ -1051,6 +1129,7 @@ mod tests {
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: false,
hdr_smooth_gradients: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -1061,6 +1140,16 @@ mod tests {
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
for mode in ParticleCullingMode::ALL {
let graphics = Graphics {
particle_culling: mode,
..Graphics::default()
};
assert_eq!(
Graphics::parse(&graphics.serialize()).particle_culling,
mode
);
}
assert_eq!(
Graphics::parse("entityCulling:true\n").entity_culling,
EntityCullingMode::Synchronous
@@ -1082,6 +1171,10 @@ mod tests {
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
// Smooth Gradients is on, and draws a 16-bit frame only with HDR on.
assert!(Graphics::default().hdr_smooth_gradients && !Graphics::default().wide_frame());
assert!(Graphics::parse("hdr:true\n").wide_frame());
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
+141 -11
View File
@@ -7,7 +7,7 @@
use crate::{
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::{self as style, Cost},
hud::{HudAtlas, HudBatch},
@@ -66,6 +66,7 @@ pub enum H {
BlueMapRadius,
CaveCulling,
EntityCulling,
ParticleCulling,
LeafCulling,
CacheShulkerBoxes,
CacheSigns,
@@ -103,6 +104,7 @@ pub enum H {
ShadowCache,
GraphicsApi,
TranslucentBatching,
HdrSmoothGradients,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -379,6 +381,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Hides entities behind opaque blocks.",
),
h(
H::ParticleCulling,
None,
"Skips offscreen, subpixel and terrain-hidden particles.",
),
h(
H::LeafCulling,
Saves,
@@ -490,6 +497,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrSmoothGradients,
Low,
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
),
h(
H::HdrCalibrate,
None,
@@ -620,6 +632,9 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
},
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
LeafCullingMode::Hidden => {
"Drops leaf faces behind two leaf layers: faster, same crowns."
}
LeafCullingMode::Solid => {
"Leaves fully inside a canopy draw solid: fewer see-through layers."
}
@@ -628,6 +643,15 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
}
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
},
(Item::Horizon(H::ParticleCulling), _) => match s.horizon.particle_culling {
ParticleCullingMode::Off => "Draws every particle.",
ParticleCullingMode::Synchronous => {
"Skips offscreen, subpixel and terrain-hidden particles."
}
ParticleCullingMode::Asynchronous => {
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work."
}
},
_ => text,
}
}
@@ -719,6 +743,7 @@ impl H {
),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
H::CacheShulkerBoxes => labelled("Shulker Boxes", on_off(g.cache_shulker_boxes)),
H::CacheSigns => labelled("Signs", on_off(g.cache_signs)),
@@ -805,12 +830,16 @@ impl H {
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
// These two are greyed out where the device can't (`Item::blocked`): Off, as
// they are there, while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::HdrSmoothGradients => labelled(
"Smooth Gradients",
on_off(g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -871,6 +900,9 @@ impl H {
H::EntityCulling => {
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
}
H::ParticleCulling => {
g.particle_culling = step(&ParticleCullingMode::ALL, g.particle_culling, back)
}
H::LeafCulling => {
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
}
@@ -920,6 +952,7 @@ impl H {
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::HdrSmoothGradients => g.hdr_smooth_gradients = !g.hdr_smooth_gradients,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -951,7 +984,8 @@ impl Item {
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -963,16 +997,23 @@ impl Item {
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(H::HdrSmoothGradients)
if !crate::graphics::smooth_gradients_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
Item::Horizon(
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
@@ -1010,6 +1051,9 @@ impl Item {
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -1076,6 +1120,7 @@ impl Item {
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
| H::HdrSmoothGradients
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -2591,6 +2636,31 @@ mod tests {
assert!(!ui.release());
}
#[test]
fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let mut s = Settings::default();
s.horizon.hdr = true;
let item = Item::Horizon(H::HdrSmoothGradients);
// Where the device can't, the saved On reads Off.
crate::graphics::offer_smooth_gradients(false);
assert_eq!(
item.blocked(&s),
Some("Your GPU or graphics API doesn't support it.")
);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: Off");
assert_eq!(item.switch(&s), Some(false));
assert!(s.horizon.hdr_smooth_gradients);
// Where it can, it waits only for HDR, like the rest of the page.
crate::graphics::offer_smooth_gradients(true);
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
assert_eq!(item.switch(&s), Some(true));
s.horizon.hdr = false;
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
@@ -2991,6 +3061,42 @@ mod tests {
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
}
#[test]
fn particle_culling_modes_are_opt_in_and_cycle_from_its_row() {
let mut s = Settings::default();
let item = Item::Horizon(H::ParticleCulling);
assert_eq!(s.horizon.particle_culling, ParticleCullingMode::Off);
assert_eq!(item.label(&s).plain(), "Particle Culling: Off");
for mode in ParticleCullingMode::ALL {
s.horizon.particle_culling = mode;
for preset in Preset::ALL {
crate::graphics::apply_preset(preset, &mut s);
assert_eq!(s.horizon.particle_culling, mode);
}
assert_eq!(
crate::graphics::Graphics::parse(&s.horizon.serialize()).particle_culling,
mode
);
}
s.horizon.particle_culling = ParticleCullingMode::Off;
let mut ui = GraphicsUi::default();
let r = row(&mut ui, &s, item);
for (mode, label) in [
(ParticleCullingMode::Synchronous, "Synchronous"),
(ParticleCullingMode::Asynchronous, "Asynchronous"),
(ParticleCullingMode::Off, "Off"),
] {
s.horizon.preset = Preset::Balanced;
assert_eq!(
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.particle_culling, mode);
assert_eq!(s.horizon.preset, Preset::Custom);
assert_eq!(item.label(&s).plain(), format!("Particle Culling: {label}"));
}
}
#[test]
fn graphics_cycle_button_cycles_forward_and_shift_click_cycles_backward_with_wrapping() {
let mut s = Settings::default();
@@ -3098,7 +3204,7 @@ mod tests {
let item = Item::Horizon(H::LeafCulling);
assert_eq!(item.switch(&s), None);
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
for label in ["Hidden Faces", "Solid", "Solid Aggressive", "Hollow", "Off"] {
item.cycle(&mut s, false, FB);
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
assert_eq!(
@@ -3117,7 +3223,12 @@ mod tests {
.unwrap();
assert_eq!(culling.0, Page::Performance);
let block: Vec<Item> = culling.1.iter().map(|r| r.item).collect();
for h in [H::CaveCulling, H::EntityCulling, H::LeafCulling] {
for h in [
H::CaveCulling,
H::EntityCulling,
H::ParticleCulling,
H::LeafCulling,
] {
assert!(block.contains(&Item::Horizon(h)), "{h:?}");
}
// The block-entity caches are the group after it, in this order.
@@ -3168,7 +3279,7 @@ mod tests {
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
[Action::ButtonClick, Action::Changed]
);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hidden);
assert_eq!(s.horizon.preset, Preset::Custom);
}
@@ -3333,36 +3444,47 @@ mod tests {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
s.horizon.particle_culling = ParticleCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text, particle, particle_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
"Partikel-Culling: Asynchron",
"Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
"Pomijanie cząsteczek: Asynchroniczne",
"Pomija cząsteczki poza ekranem, mniejsze niż piksel i zasłonięte przez teren, odciążając wątek renderowania.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
"Відсікання частинок: Асинхронно",
"Пропускає частинки поза екраном, менші за піксель і закриті місцевістю, зменшуючи роботу потоку рендерингу.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
"粒子剔除: 异步",
"跳过屏幕外、小于一个像素和被地形遮挡的粒子,减少渲染线程的工作量。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
"Occlusion des particules: Asynchrone",
"Ignore les particules hors écran, trop petites ou masquées par le terrain avec moins de travail sur le thread de rendu.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
@@ -3371,11 +3493,19 @@ mod tests {
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
item(H::ParticleCulling).label(&s).plain(),
lang::get(description(item(H::ParticleCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
(
leaf.into(),
entity.into(),
leaf_text.into(),
particle.into(),
particle_text.into(),
),
"{code}"
);
}
+10 -2
View File
@@ -58,6 +58,14 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
const BT709_TO_BT2020 = mat3x3<f32>(
vec3(0.6274, 0.0691, 0.0164),
@@ -152,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
return vec4(nits / 80.0, 1.0);
}
let f = textureLoad(frame, at, 0);
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
@@ -162,7 +170,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
+7 -1
View File
@@ -3008,8 +3008,14 @@ mod tests {
}
/// A resource root holding one animated block texture: a strip of two
/// 16x16 frames, red then blue, whose mcmeta starts on the blue one.
/// Each call gets its own folder: tests run side by side, and one adds
/// a strip of its own that another would otherwise find.
fn animated_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-atlas-anim-{}", std::process::id()));
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root =
std::env::temp_dir().join(format!("horizon-atlas-anim-{}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let block = root.join("assets/minecraft/textures/block");
fs::create_dir_all(&block).unwrap();
write_strip(
+113 -28
View File
@@ -38,10 +38,27 @@ const QUEUED_PER_SAMPLER: usize = 32;
const MAX_REFRESHED: usize = 64;
/// Threads that sample far tiles beside the mesher. Sampling is the cold-load
/// bottleneck (each task evaluates a shared density lattice for 256 columns),
/// so medium machines use half their cores, not a quarter.
/// so medium machines use two thirds of their cores, not half.
///
/// The mesher has little to do until tiles are sampled, so its threads sit
/// idle through the part of a cold build that waits on sampling. Measured on
/// a 12-thread machine building 4336 tiles at LOD distance 256, from the game
/// answering the control socket until every target tile is sampled, four runs
/// a configuration:
///
/// | samplers / meshers | sampling | whole build |
/// |---|---|---|
/// | 6 / 5 (before) | 15.0 s | 23.9 s |
/// | 8 / 3 | 11.8 s | 18.6 s |
/// | 9 / 2 | 10.6 s | 19.1 s |
///
/// Nine samplers finish sampling sooner still, but leave the mesher two
/// threads and the whole build ends later. The cap keeps a large machine
/// from starving its mesher, and `meshing_threads` keeps the frame and the
/// ticks a core either way.
fn sampling_threads(cores: usize) -> usize {
if cores >= 8 {
(cores / 2).min(8)
(cores * 2 / 3).min(8)
} else {
(cores / 4).clamp(1, 3)
}
@@ -179,7 +196,11 @@ pub struct Column {
}
impl Column {
/// Reduce an entire horizontal footprint, rather than inflating its corner
/// column (a single leaf used to become a whole distant tree-sized slab).
/// column. A height is drawn where any column of the footprint holds a
/// block there, in the material most of them hold: anything joined to
/// the ground stays joined at every width. Drawing only heights most of
/// the footprint held dropped trunks (one column of 16 or 256) under the
/// crowns they hold up, and the crowns hung in the air as slabs.
pub fn reduced(columns: &[Column; 256], x: usize, z: usize, width: usize) -> Self {
if width == 1 {
return columns[z * 16 + x].clone();
@@ -203,7 +224,7 @@ impl Column {
let mut bottom = MIN_Y as i16;
for (y, material, change) in events {
if bottom < y {
if occupied * 2 >= (width * width) as i32
if occupied > 0
&& let Some(&((state, biome), _)) =
votes.iter().max_by_key(|&(material, count)| {
(*count, std::cmp::Reverse((material.0.0, material.1)))
@@ -854,7 +875,16 @@ struct MeshInput {
}
struct ReducedColumns {
columns: Arc<[Column; 256]>,
levels: [Vec<Column>; 4],
/// The chunk reduced to cells 2, 4, 8 and 16 blocks wide, each built the
/// first time a tile of that width asks for it.
///
/// One chunk is covered by one far tile, so one of the four is what gets
/// read, and a chunk whose detail arrives while full-detail terrain still
/// draws it reads none. Building all four up front cost 405 us of a far
/// terrain thread for every chunk the world sent (`Column::reduced` walks
/// every span of the footprint four times over), and held 85 columns
/// beside the chunk's 256.
levels: [std::sync::OnceLock<Vec<Column>>; 4],
}
impl MeshInput {
/// The column of every cell: the tile's own samples unless the snapshot
@@ -884,25 +914,26 @@ impl MeshInput {
}
impl ReducedColumns {
fn new(columns: Arc<[Column; 256]>) -> Self {
let levels = std::array::from_fn(|level| {
let width = 2 << level;
Self {
columns,
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
}
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
return &self.columns[z * 16 + x];
}
let level = width.trailing_zeros() as usize - 1;
let reduced = self.levels[level].get_or_init(|| {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
let columns = &columns;
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
});
Self { columns, levels }
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
&self.columns[z * 16 + x]
} else {
&self.levels[width.trailing_zeros() as usize - 1]
[(z / width) * (16 / width) + x / width]
}
&reduced[(z / width) * (16 / width) + x / width]
}
}
enum Job {
@@ -3291,7 +3322,7 @@ mod tests {
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
fn coarse_voxels_keep_what_any_column_holds_so_crowns_stay_on_trunks() {
let span = |min, max, state| Span {
min,
max,
@@ -3303,8 +3334,8 @@ mod tests {
surface: None,
spans: vec![span(0, 4, 1)].into(),
});
columns[0].spans.push(span(8, 10, 2));
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, [span(0, 4, 1)]);
// One trunk column under a crown over the whole footprint.
columns[0].spans.push(span(4, 12, 2));
for z in 0..4 {
for x in 0..4 {
columns[z * 16 + x].spans.push(span(12, 14, 3));
@@ -3312,17 +3343,58 @@ mod tests {
}
assert_eq!(
Column::reduced(&columns, 0, 0, 4).spans,
[span(0, 4, 1), span(12, 14, 3)]
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
);
assert_eq!(Column::reduced(&columns, 0, 0, 1), columns[0]);
let reduced = ReducedColumns::new(Arc::new(columns));
assert_eq!(
reduced.get(0, 0, 1).spans,
[span(0, 4, 1), span(8, 10, 2), span(12, 14, 3)]
reduced.get(0, 0, 4).spans,
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)]
);
assert_eq!(reduced.get(0, 0, 4).spans, [span(0, 4, 1), span(12, 14, 3)]);
assert_eq!(reduced.get(4, 8, 4).spans, [span(0, 4, 1)]);
assert_eq!(reduced.get(0, 0, 16).spans, [span(0, 4, 1)]);
assert_eq!(
reduced.get(0, 0, 16).spans,
[span(0, 4, 1), span(4, 12, 2), span(12, 14, 3)],
"a crown over one corner of a wide cell still sits on its trunk"
);
}
/// A chunk is reduced to the one cell width that reads it, when it does.
///
/// Reducing to all four widths up front cost 405 us of a far terrain
/// thread for every chunk the world sent, where at most one is read.
#[test]
fn a_chunk_is_reduced_only_to_the_width_that_reads_it() {
let built = |reduced: &ReducedColumns| {
reduced
.levels
.iter()
.enumerate()
.filter(|(_, level)| level.get().is_some())
.map(|(level, _)| 2usize << level)
.collect::<Vec<_>>()
};
let reduced = ReducedColumns::new(Arc::new(std::array::from_fn(|i| Column {
surface: None,
spans: vec![Span {
min: 0,
max: 4 + (i % 3) as i16,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into(),
})));
assert!(built(&reduced).is_empty(), "nothing is reduced unread");
// A tile of full-detail cells reads the chunk's own columns.
reduced.get(3, 5, 1);
assert!(built(&reduced).is_empty());
reduced.get(4, 8, 4);
assert_eq!(built(&reduced), [4]);
// Read again, and the reduction is the one already built.
reduced.get(0, 0, 4);
assert_eq!(built(&reduced), [4]);
reduced.get(0, 0, 16);
assert_eq!(built(&reduced), [4, 16]);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
@@ -4028,9 +4100,22 @@ mod tests {
}
#[test]
fn extra_sampling_threads_only_on_large_machines() {
assert_eq!([1, 2, 4, 8, 11].map(sampling_threads), [1, 1, 1, 4, 5]);
assert_eq!([12, 16, 64].map(sampling_threads), [6, 8, 8]);
assert_eq!([2, 8, 12, 16].map(meshing_threads), [1, 3, 5, 7]);
// Small machines are unchanged: a quarter of their cores, never more
// than three, so the near chunks and the frame keep theirs.
assert_eq!([1, 2, 4].map(sampling_threads), [1, 1, 1]);
// From eight, two thirds rather than half, still capped at eight.
assert_eq!([8, 11, 12].map(sampling_threads), [5, 7, 8]);
assert_eq!([16, 64].map(sampling_threads), [8, 8]);
assert_eq!([2, 8, 12, 16].map(meshing_threads), [1, 2, 3, 7]);
// From four cores up the two pools together leave one for the frame
// and the ticks. Below that both clamp to one and there is nothing
// to spare, which is the existing behaviour.
for cores in [4usize, 8, 11, 12, 16, 64] {
assert!(
sampling_threads(cores) + meshing_threads(cores) < cores,
"{cores} cores leaves nothing for the frame"
);
}
}
/// Review point 3 on #4747: a result from a batch that is no longer the
+62 -14
View File
@@ -6,6 +6,7 @@
mod fluid;
mod leaf_culling;
mod lighting;
pub mod packed;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
@@ -265,6 +266,12 @@ pub struct SectionMesh {
/// model order before.
pub sides: Sides,
pub translucent: Vec<TerrainVertex>,
/// `solid` in the compact layout (`packed`), when the renderer reads it and it is
/// smaller; empty otherwise. Made by `sort_solid`.
pub packed: Vec<[u32; 6]>,
/// The tint of each solid quad as the mesher multiplied it (`packed::UNKNOWN_TINT` where
/// it does not say), for `packed`; dropped once that is made.
pub(crate) solid_tints: Vec<u32>,
/// VisGraph: bit `a * 6 + b` is set when faces `a` and `b` (Direction
/// order) are connected through non-opaque blocks.
pub visibility: u64,
@@ -290,6 +297,8 @@ impl Default for SectionMesh {
solid: Vec::new(),
sides: Sides::default(),
translucent: Vec::new(),
packed: Vec::new(),
solid_tints: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
@@ -322,6 +331,8 @@ impl SectionMesh {
pub fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.packed.clear();
self.solid_tints.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
@@ -333,15 +344,27 @@ impl SectionMesh {
pub(crate) fn capacity_bytes(&self) -> usize {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.packed.capacity() * std::mem::size_of::<[u32; 6]>()
+ self.solid_tints.capacity() * std::mem::size_of::<u32>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
/// Then lays them out compactly (`packed`) while the renderer reads that.
pub fn sort_solid(&mut self) {
self.sides = sides::sort(&mut self.solid);
let quads = self.solid.len() / 4;
if !self.solid_tints.is_empty() {
self.solid_tints.resize(quads, packed::UNKNOWN_TINT);
}
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
self.bounds = self.compute_bounds();
self.packed.clear();
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
self.packed = packed::layout(&self.solid, &self.solid_tints);
}
self.solid_tints.clear();
}
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
@@ -1382,7 +1405,8 @@ fn pack_coords(coords: u32) -> u16 {
pub struct Mesher<'a> {
/// CardinalLighting.NETHER: brighter up/down faces.
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
/// only the dimension's up-face brightness (`lighting::pack_flat`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
@@ -1519,12 +1543,24 @@ impl Mesher<'_> {
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
&& state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
let culled_leaf = match self.leaf_culling {
LeafCullingMode::Hollow | LeafCullingMode::Hidden => {
state.leaves
&& other.leaves
&& leaf_culling::in_section(x + dx, y + dy, z + dz)
&& (self.leaf_culling == LeafCullingMode::Hollow
|| leaf_culling::backed(
pad,
info,
region,
base_y,
[x + 2 * dx, y + 2 * dy, z + 2 * dz],
))
}
_ => false,
};
visible[d as usize] = !opaque_leaves
&& !hollow_leaf
&& !culled_leaf
&& self
.visibility
.should_render_face(
@@ -1625,8 +1661,10 @@ impl Mesher<'_> {
None => ao,
};
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
let directional = if self.shader_pack_lighting || !q.shade {
let directional = if !q.shade {
1.0
} else if self.shader_pack_lighting {
lighting::pack_flat(self.nether_shading)
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
@@ -1770,8 +1808,8 @@ impl Mesher<'_> {
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0), tint),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0), tint),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
@@ -1779,7 +1817,8 @@ impl Mesher<'_> {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
let piece = piece.map(|c| c.0);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece, tint);
}
}
}
@@ -1823,7 +1862,7 @@ impl Mesher<'_> {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&whole, s, t).0);
self.quad_as(out, false, tile, piece, false);
self.quad_as(out, false, tile, piece, false, tint);
}
}
}
@@ -1836,8 +1875,9 @@ impl Mesher<'_> {
solid_leaves: bool,
tile: TileLayer,
v: [TerrainVertex; 4],
tint: Option<u32>,
) {
self.quad_as(out, solid_leaves, tile, v, true);
self.quad_as(out, solid_leaves, tile, v, true, tint);
}
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
fn translucent(&self, tile: TileLayer) -> bool {
@@ -1856,7 +1896,8 @@ impl Mesher<'_> {
}
}
/// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only
/// when `by_sprite`: an overlay's pass is its rule's.
/// when `by_sprite`: an overlay's pass is its rule's. `tint`: what its grey levels were
/// multiplied by, for the compact layout (`packed`).
fn quad_as(
&self,
out: &mut SectionMesh,
@@ -1864,6 +1905,7 @@ impl Mesher<'_> {
tile: TileLayer,
mut v: [TerrainVertex; 4],
by_sprite: bool,
tint: Option<u32>,
) {
let TileLayer::Layer {
layer,
@@ -1897,6 +1939,12 @@ impl Mesher<'_> {
vertex.layer = layer;
}
target.extend(v);
if !translucent {
// Quads other code put in `solid` (lava) have no tint known.
let quad = out.solid.len() / 4 - 1;
out.solid_tints.resize(quad, packed::UNKNOWN_TINT);
out.solid_tints.push(tint.unwrap_or(packed::WHITE));
}
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
/// alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
use horizon_assets::model::Direction as D;
let cardinal = |d: D| {
if shader_pack_lighting {
1.0
lighting::pack_flat(nether)
} else {
lighting::cardinal(nether, d)
}
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
Some(layer) if face_state.overlay => (layer, true),
_ => (flow, false),
};
let side = if matches!(d, Direction::North | Direction::South) {
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
let side = if shader_pack_lighting {
1.0
} else if matches!(d, Direction::North | Direction::South) {
cardinal(D::North)
} else {
cardinal(D::West)
@@ -12,7 +12,9 @@
//! Aggressive` runs a multi-source breadth-first search from leaves touching
//! openings, solidifying leaves with a single open face to thin the canopy shell
//! by one layer. `Hollow` directly drops faces shared between two leaves within
//! the section, preserving boundaries and cavity faces.
//! the section, preserving boundaries and cavity faces. `Hidden` drops only
//! those whose faced leaf has another leaf or an opaque cube behind it
//! ([`backed`]), so the layer seen through the canopy's outer holes stays.
use super::{B, Info, P, Padded, pi};
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
@@ -229,6 +231,21 @@ pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
}
/// `Hidden`: whether the padded cell `[x, y, z]`, the one behind a faced leaf,
/// is a leaf or a full opaque cube. A cell without real data never is.
#[inline]
pub(super) fn backed(
pad: &Padded,
info: &[Info],
region: &[[Option<&Chunk>; 3]; 3],
base_y: i32,
[x, y, z]: [i32; 3],
) -> bool {
info.get(pad.blocks[pi(x, y, z)] as usize)
.is_some_and(|state| state.leaves || state.opaque_cube)
&& known(region, base_y, x, y, z)
}
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
/// does not count: `Padded::fill` copies the centre's edge column into it, which
/// would make a canopy look enclosed. A Y outside the column counts as unknown
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
D::West | D::East => 0.6,
}
}
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
@@ -0,0 +1,343 @@
//! The compact layout of a section's solid quads in the arena: most of them are whole faces
//! of a block's cube on the block grid, and those take one 24-byte record each instead of
//! four 24-byte vertices. terrain.wgsl's `section_corner` expands a record into the very
//! vertex words `TerrainVertex` would hold (`packed_vertex` here is the same on the CPU, and
//! a record is kept only when it gives back the quad's four vertices bit for bit), so every
//! pass draws the same picture from either layout.
//!
//! A mesh's solid range is then one record per quad in mesh order (so `Sides` and the draws
//! count quads as before), padding to a multiple of 4 slots, and the quads no record fits as
//! their four vertices: their record holds the distance to those (`FULL`). A record:
//!
//! - word 0: the tint (0xRRGGBB as the mesher multiplies it) and the colour's alpha byte;
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
use std::sync::atomic::{AtomicBool, Ordering};
/// A solid quad's tint the mesher does not know (fluids): only untinted colours pack.
pub const UNKNOWN_TINT: u32 = u32::MAX;
/// The tint of an untinted quad, whose colour is its grey level.
pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
/// Whether meshes sorted from now on get the compact layout: set by the renderer while its
/// passes read it (no shader pack).
static PACK: AtomicBool = AtomicBool::new(false);
pub fn pack_quads(on: bool) {
PACK.store(on, Ordering::Relaxed);
}
pub fn packing() -> bool {
PACK.load(Ordering::Relaxed)
}
/// The order corners go round a face: (du, dw) of each step.
const ROUND: [[u32; 2]; 4] = [[0, 0], [1, 0], [1, 1], [0, 1]];
/// `quad`'s record, if one gives back its four vertices exactly. `tint`: what the mesher
/// multiplied its grey levels by (`UNKNOWN_TINT` when it does not say).
pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
// Corners on the block grid of the section.
let mut p = [[0u32; 3]; 4];
for (to, v) in p.iter_mut().zip(quad) {
for (c, &x) in to.iter_mut().zip(&v.pos) {
if !(0.0..=16.0).contains(&x) || x.fract() != 0.0 {
return None;
}
*c = x as u32;
}
}
let axis = (0..3).find(|&a| p.iter().all(|c| c[a] == p[0][a]))?;
let (u, w) = ((axis + 1) % 3, (axis + 2) % 3);
let lo = [u, w].map(|k| p.iter().map(|c| c[k]).min().unwrap_or(0));
if lo.iter().any(|&l| l > 15) {
return None;
}
let mut steps = [0u32; 4];
for (step, c) in steps.iter_mut().zip(&p) {
let d = [c[u] - lo[0], c[w] - lo[1]];
*step = ROUND.iter().position(|r| *r == d)? as u32;
}
let (start, dir) = (0..8u32).map(|n| (n & 3, n >> 2)).find(|&(start, dir)| {
(0..4u32).all(|i| {
let step = if dir == 1 { 4 - i } else { i };
steps[i as usize] == (start + step) & 3
})
})?;
let plane = p[0][axis];
let mut block = [0u32; 3];
block[axis] = plane.min(15);
block[u] = lo[0];
block[w] = lo[1];
let side = plane - block[axis];
let orient = block[0]
| block[1] << 4
| block[2] << 8
| (axis as u32) << 12
| side << 14
| start << 15
| dir << 17;
// UVs in 1/32 of the sprite, at most two values each way.
let mut ranges = [[u32::MAX, 0u32]; 2];
let mut uvs = [[0u32; 2]; 4];
for (uv, v) in uvs.iter_mut().zip(quad) {
for k in 0..2 {
let raw = u32::from(v.uv[k]);
if raw & 1023 != 0 {
return None;
}
uv[k] = raw >> 10;
ranges[k] = [ranges[k][0].min(uv[k]), ranges[k][1].max(uv[k])];
}
}
let mut select = 0u32;
for (i, uv) in uvs.iter().enumerate() {
for k in 0..2 {
if uv[k] == ranges[k][1] && uv[k] != ranges[k][0] {
select |= 1 << (2 * i + k);
} else if uv[k] != ranges[k][0] {
return None;
}
}
}
let rect = ranges[0][0] | ranges[0][1] << 6 | ranges[1][0] << 12 | ranges[1][1] << 18;
// Light in steps of 4 (smooth light averages four levels of 16).
let mut light = [0u32; 4];
for (l, v) in light.iter_mut().zip(quad) {
let (sky, block) = (u32::from(v.light & 0xFF), u32::from(v.light >> 8));
if sky & 3 != 0 || block & 3 != 0 {
return None;
}
*l = sky >> 2 | (block >> 2) << 6;
}
// The colour: each channel the grey level times the tint's, over 255.
let tint = if tint == UNKNOWN_TINT {
WHITE
} else {
tint & WHITE
};
let alpha = quad[0].color & 0xFF00_0000;
let mut grays = 0u32;
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
let (a, b) = (packed_vertex(&words, c), quad[c]);
bytemuck::bytes_of(&a) == bytemuck::bytes_of(&b)
});
exact.then_some(words)
}
/// The tint's channels, as the mesher takes them for red, green and blue.
fn channels(tint: u32) -> [u32; 3] {
[tint >> 16 & 255, tint >> 8 & 255, tint & 255]
}
/// A grey level that `tint` turns into `color`'s red, green and blue.
fn gray_of(color: u32, tint: u32) -> Option<u32> {
let want = [color & 255, color >> 8 & 255, color >> 16 & 255];
let t = channels(tint);
let gives = |g: u32| (0..3).all(|k| g * t[k] / 255 == want[k]);
let k = (0..3).max_by_key(|&k| t[k]).unwrap_or(0);
if t[k] == 0 {
return gives(0).then_some(0);
}
let lo = (want[k] * 255).div_ceil(t[k]);
let hi = ((want[k] * 255 + 254) / t[k]).min(255);
(lo..=hi).find(|&g| gives(g))
}
/// Corner `corner` of the quad `words` holds, as terrain.wgsl's `packed_corner` expands it.
pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let [w0, w1, w2, w3, w4, w5] = *words;
let orient = w5 >> 12 & 0x3_FFFF;
let block = [orient & 15, orient >> 4 & 15, orient >> 8 & 15];
let axis = (orient >> 12 & 3) as usize;
let side = orient >> 14 & 1;
let (start, dir) = (orient >> 15 & 3, orient >> 17 & 1);
let i = corner as u32;
let step = (start + if dir == 1 { 4 - i } else { i }) & 3;
let [du, dw] = ROUND[step as usize];
let mut pos = block;
pos[axis] += side;
pos[(axis + 1) % 3] += du;
pos[(axis + 2) % 3] += dw;
let select = w2 >> 24;
let us = select >> (2 * i) & 1;
let vs = select >> (2 * i + 1) & 1;
let uv = [w2 >> (6 * us) & 63, w2 >> (12 + 6 * vs) & 63].map(|k| (k << 10) as u16);
let light = [w3 >> 16, w4, w4 >> 12, w5][corner] & 0xFFF;
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
}
}
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
/// nothing when it would be no smaller than their vertices.
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
let quads = solid.as_chunks::<4>().0;
let records: Vec<Option<[u32; 6]>> = quads
.iter()
.zip(tints)
.map(|(q, &t)| pack_quad(q, t))
.collect();
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
if n + pad + 4 * full >= 4 * n {
return Vec::new();
}
let mut out = Vec::with_capacity(n + pad + 4 * full);
let mut next = n + pad;
for (i, record) in records.iter().enumerate() {
out.push(record.unwrap_or_else(|| {
let at = next;
next += 4;
[(at - i) as u32, 0, 0, 0, 0, FULL]
}));
}
out.resize(n + pad, [0; 6]);
for (q, record) in quads.iter().zip(&records) {
if record.is_none() {
out.extend(
q.iter()
.map(|v| bytemuck::cast::<TerrainVertex, [u32; 6]>(*v)),
);
}
}
out
}
/// Quads `layout` gave a record of their own.
pub fn packed_quads(layout: &[[u32; 6]], quads: usize) -> usize {
layout
.iter()
.take(quads)
.filter(|w| w[5] & FULL == 0)
.count()
}
#[cfg(test)]
mod tests {
use super::*;
const TINT: u32 = 0x7F_B238;
/// An opaque colour the mesher makes of grey level `g` times `tint`.
fn tinted(g: u32, tint: u32) -> u32 {
let [r, gr, b] = channels(tint).map(|t| g * t / 255);
r | gr << 8 | b << 16 | 0xFF00_0000
}
/// The top face of block (3, 15, 5), corners in `order`: tinted, smooth lit, with
/// ambient occlusion darkening its corners differently.
fn top(order: [usize; 4]) -> [TerrainVertex; 4] {
let pos = [
[3.0, 16.0, 5.0],
[3.0, 16.0, 6.0],
[4.0, 16.0, 6.0],
[4.0, 16.0, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
let grays = [200, 180, 160, 200];
order.map(|i| TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 7,
light: 0xF0 | 0x40 << 8,
color: tinted(grays[i], TINT),
})
}
fn same(a: &TerrainVertex, b: &TerrainVertex) -> bool {
bytemuck::bytes_of(a) == bytemuck::bytes_of(b)
}
#[test]
fn a_whole_cube_face_takes_one_record_either_way_round() {
for order in [[0, 1, 2, 3], [2, 3, 0, 1], [0, 3, 2, 1]] {
let quad = top(order);
let words = pack_quad(&quad, TINT).expect("a whole face on the block grid packs");
for (corner, v) in quad.iter().enumerate() {
assert!(
same(&packed_vertex(&words, corner), v),
"{order:?} corner {corner}"
);
}
}
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
let mut slab = top([0, 1, 2, 3]);
for v in &mut slab {
v.pos[1] = 8.5;
}
assert!(pack_quad(&slab, TINT).is_none());
// A UV between the 1/32 steps.
let mut uv = top([0, 1, 2, 3]);
uv[2].uv[0] += 1;
assert!(pack_quad(&uv, TINT).is_none());
// Light between the steps of 4.
let mut light = top([0, 1, 2, 3]);
light[1].light += 1;
assert!(pack_quad(&light, TINT).is_none());
}
#[test]
fn a_quad_without_a_record_points_at_its_own_vertices() {
let mut odd = top([0, 1, 2, 3]);
for v in &mut odd {
v.pos[1] = 8.5;
}
let mut solid: Vec<TerrainVertex> = Vec::new();
for _ in 0..4 {
solid.extend(top([0, 1, 2, 3]));
}
solid.extend(odd);
let out = layout(&solid, &[TINT; 5]);
// Five records, padded to eight slots, then the odd quad's four vertices.
assert_eq!(out.len(), 12);
assert_eq!(packed_quads(&out, 5), 4);
assert_eq!(out[4][5], FULL);
let at = 4 + out[4][0] as usize;
for (slot, v) in out[at..at + 4].iter().zip(&odd) {
assert!(same(&bytemuck::cast::<[u32; 6], TerrainVertex>(*slot), v));
}
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
}
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let mut chunk = Chunk::empty(0, 0);
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let colours = |nether_shading| {
let mut mesher = Mesher {
nether_shading,
shader_pack_lighting: true,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility: visibility.clone(),
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
assert!(all.len() >= 48, "{} vertices", all.len());
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
colours.sort_unstable();
colours.dedup();
colours
};
assert_eq!(colours(true), [0xE5_E5E5]);
assert_eq!(colours(false), [0xFF_FFFF]);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
@@ -74,7 +74,7 @@ fn slot(grouped: bool, n: usize) -> (usize, bool) {
/// Where each part of each bucket of a mesh's solid quads ends, and the
/// planes that tell whether a bucket can face the eye.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Sides {
/// The solid quads up to the end of each part of each bucket, in mesh
/// order (`slot`).
@@ -205,13 +205,21 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
#[cfg(test)]
fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_tinted(solid, &mut Vec::new())
}
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
let grouped = GROUPED.load(Ordering::Relaxed);
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
}
/// `sort`, with all the opaque parts first when `grouped`.
#[cfg(test)]
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, &mut Vec::new(), grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
@@ -222,6 +230,7 @@ struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
tints: Vec<u32>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
@@ -233,9 +242,15 @@ fn total_order_key(v: f32) -> u32 {
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
fn sort_with(
solid: &mut Vec<TerrainVertex>,
tints: &mut Vec<u32>,
grouped: bool,
scratch: &mut SortScratch,
) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let quads_len = quads.len();
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
// Each quad's part, by its place in mesh order (`slot`).
let position = |b: usize, cutout: bool| match grouped {
@@ -297,6 +312,12 @@ fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortSc
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
if tints.len() == quads_len {
let sorted_tints = &mut scratch.tints;
sorted_tints.clear();
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
std::mem::swap(tints, sorted_tints);
}
}
Sides {
ends: slot_ends,
+8 -3
View File
@@ -99,9 +99,14 @@ pub(crate) fn prepare_with_models(
.filter(|name| registry.default_state(name).is_some())
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
.collect();
let mut catalog =
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
catalog.bake_standalone(root, sprites, models, standalone())?;
let mut catalog = {
let _step = crate::spans::startup::step("core.bake_states");
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?
};
{
let _step = crate::spans::startup::step("core.bake_standalone");
catalog.bake_standalone(root, sprites, models, standalone())?;
}
let mut placements = Vec::new();
let mut states = Vec::new();
for (index, (name, properties)) in GALLERY.iter().enumerate() {
@@ -6,7 +6,7 @@
// the depth bounds stage, which sees the unfaded alpha.
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
let faded = 1.0 - linear_fog(in.fog_distance.x, 0.0, globals.fog_ends.y);
return vec4(color.rgb, color.a * select(1.0, faded, fade));
}
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
@@ -263,9 +263,6 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
for part in MODEL_PARTS {
t.push(pb(&format!("modelPart_{part}"), true));
}
// Horizon's own: the outer skin layer drawn as slabs (Options → Skin
// Customization), read by the renderer when a skin is resolved.
t.push(pb("threeDSkinLayers", false));
t
});
+19 -3
View File
@@ -730,7 +730,7 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::SkinLayers3d => tr(if s.three_d_skin_layers {
Opt::SkinLayers3d => tr(if s.extras.three_d_skin_layers {
"options.on"
} else {
"options.off"
@@ -1019,7 +1019,7 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::SkinLayers3d => s.three_d_skin_layers = !s.three_d_skin_layers,
Opt::SkinLayers3d => s.extras.three_d_skin_layers = !s.extras.three_d_skin_layers,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
@@ -8039,7 +8039,7 @@ mod tests {
// Also verify Act::Extra on Page::Extras
ui.show(Page::Extras);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
if let Some(extra_btn) = widgets.iter().find(|wd| {
matches!(
wd.kind,
@@ -8050,6 +8050,22 @@ mod tests {
}
)
}) {
// Extras has enough rows to scroll: click the cycle button inside
// the list's viewport, as a player would after scrolling to it.
ui.scroll = (extra_btn.y - top).max(0.0);
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let extra_btn = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
..
}
)
})
.unwrap();
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
use horizon_cosmetics::prefs::Others;
assert_eq!(s.extras.cosmetics_others, Others::Shown);
+96 -3
View File
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
/// non-negative ints whose second is the minor. None: it does not parse.
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
let non_negative =
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
// counts as its whole part (97), as it does for the game.
let non_negative = |v: &serde_json::Value| {
v.as_i64()
.or_else(|| {
v.as_f64()
.filter(|f| f.is_finite())
.map(|f| f.trunc() as i64)
})
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
};
match v {
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
}
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
// supplies the file or one above it; vanilla's own sits below every pack.
let hidden: BTreeSet<String> = files
let mut hidden: BTreeSet<String> = files
.iter()
.filter(|(name, _)| !name.ends_with(".mcmeta"))
.filter_map(|(name, (owner, _))| {
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
}
})
.collect();
// A companion from below the winning colour would describe another surface.
// A map-only pack above it is intentional and remains valid.
for (name, (owner, _)) in &files {
let Some(stem) = name.strip_suffix(".png") else {
continue;
};
if stem.ends_with("_n") || stem.ends_with("_s") {
continue;
}
for suffix in ["_n.png", "_s.png"] {
let map = format!("{stem}{suffix}");
if files
.get(&map)
.is_none_or(|(map_owner, _)| map_owner < owner)
{
hidden.insert(map);
}
}
}
for name in &hidden {
files.remove(name);
}
@@ -1257,6 +1285,16 @@ mod tests {
.unwrap_err()
.contains("deprecated")
);
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
// that meant `[97, 1]` still loads and counts as compatible).
assert_eq!(
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
Ok(((97, 0), (97, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97.9,"max_format":98.5}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":97,"max_format":98}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
@@ -1469,6 +1507,61 @@ mod tests {
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
}
#[test]
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
let root =
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
let vanilla = root.join("vanilla");
let relative = "assets/minecraft/textures/block";
fs::create_dir_all(vanilla.join(relative)).unwrap();
let names = ["stone.png", "stone_n.png", "stone_s.png"];
for name in names {
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
}
for (pack, files) in [
("low", vec!["stone_n.png", "stone_s.png"]),
("colour", vec!["stone.png"]),
("maps", vec!["stone_n.png"]),
] {
let dir = root.join("packs").join(pack);
fs::create_dir_all(dir.join(relative)).unwrap();
fs::write(
dir.join("pack.mcmeta"),
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
)
.unwrap();
for name in files {
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
}
}
let all = discover(&vanilla, &root.join("packs"));
let order = |names: &[&str]| {
names
.iter()
.map(|name| {
all.iter()
.find(|p| p.id == format!("file/{name}"))
.unwrap()
.clone()
})
.collect::<Vec<_>>()
};
let out = root.join("out");
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone.png")).unwrap(),
b"colour"
);
assert!(!out.join(relative).join("stone_n.png").exists());
assert!(!out.join(relative).join("stone_s.png").exists());
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
assert_eq!(
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
b"maps"
);
assert!(!out.join(relative).join("stone_s.png").exists());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn overlay_layers_packs_over_vanilla() {
// `&` would split an unquoted `cmd /C mklink` line.
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
// Version 7 stacks blockstate files (every pack's definition, bottom first).
const VERSION: u32 = 7;
const VERSION: u32 = 8;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
+301 -23
View File
@@ -19,6 +19,7 @@ use horizon_core::{
};
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{
array,
collections::{HashMap, HashSet},
path::Path,
};
@@ -527,7 +528,7 @@ pub fn breaking_position(
}
}
// Vanilla consumes all three doubles before replacing the hit-face coordinate.
let mut p = std::array::from_fn(|i| {
let mut p = array::from_fn(|i| {
f64::from(pos[i])
+ random.next_double() * (bounds[i + 3] - bounds[i] - f64::from(0.2f32))
+ f64::from(0.1f32)
@@ -659,18 +660,16 @@ pub fn destroy_positions(
mut emit: impl FnMut([f64; 3], [f64; 3]),
) {
for b in boxes {
let widths: [f64; 3] = std::array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
let widths: [f64; 3] = array::from_fn(|i| (b[i + 3] - b[i]).min(1.0));
let counts: [usize; 3] = widths.map(|w| ((w / 0.25).ceil() as usize).max(2));
for x in 0..counts[0] {
for y in 0..counts[1] {
for z in 0..counts[2] {
let cells = [x, y, z];
let relative: [f64; 3] =
std::array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
array::from_fn(|i| (cells[i] as f64 + 0.5) / counts[i] as f64);
emit(
std::array::from_fn(|i| {
f64::from(pos[i]) + (relative[i] * widths[i] + b[i])
}),
array::from_fn(|i| f64::from(pos[i]) + (relative[i] * widths[i] + b[i])),
relative.map(|v| v - 0.5),
);
}
@@ -751,7 +750,7 @@ pub fn on_block_faces_between(
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
for _ in 0..count {
// getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55.
let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5));
let speed: [f64; 3] = array::from_fn(|_| next_double_between(random, -0.5, 0.5));
let mut p = [0.0; 3];
let mut v = [0.0; 3];
for i in 0..3 {
@@ -780,12 +779,12 @@ pub fn along_axis(
) {
let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min;
for _ in 0..count {
let p: [f64; 3] = std::array::from_fn(|i| {
let p: [f64; 3] = array::from_fn(|i| {
f64::from(pos[i])
+ 0.5
+ next_double_between(random, -1.0, 1.0) * if i == axis { 0.5 } else { 0.125 }
});
let v: [f64; 3] = std::array::from_fn(|i| {
let v: [f64; 3] = array::from_fn(|i| {
if i == axis {
next_double_between(random, -1.0, 1.0)
} else {
@@ -1268,6 +1267,113 @@ impl ParticleLightCache {
}
}
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SectionFrustumResult {
Outside,
Intersects,
Inside,
}
#[derive(Clone, Copy)]
struct CachedSectionFrustum {
section: [i32; 3],
radius_quarters: i32,
result: SectionFrustumResult,
}
struct ParticleSectionFrustumCache {
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
}
impl Default for ParticleSectionFrustumCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
}
}
}
impl ParticleSectionFrustumCache {
fn result(
&mut self,
world: [f64; 3],
camera: DVec3,
radius: f64,
frustum: &crate::gfx::Frustum,
) -> SectionFrustumResult {
let Some(section) = world_section(world) else {
return SectionFrustumResult::Intersects;
};
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
return SectionFrustumResult::Intersects;
}
let radius_quarters = (radius * 4.0).ceil() as i32;
let hash = (section[0] as u32).wrapping_mul(73_856_093)
^ (section[1] as u32).wrapping_mul(19_349_663)
^ (section[2] as u32).wrapping_mul(83_492_791)
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.section == section
&& entry.radius_quarters == radius_quarters
{
return entry.result;
}
let pad = f64::from(radius_quarters) * 0.25;
let camera = camera.to_array();
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
SectionFrustumResult::Intersects
} else {
let min = FVec3::from_array(min.map(|v| v as f32));
let max = FVec3::from_array(max.map(|v| v as f32));
if !min.is_finite() || !max.is_finite() {
SectionFrustumResult::Intersects
} else if frustum.contains(min, max) {
SectionFrustumResult::Inside
} else if frustum.intersects(min, max) {
SectionFrustumResult::Intersects
} else {
SectionFrustumResult::Outside
}
};
self.entries[slot] = Some(CachedSectionFrustum {
section,
radius_quarters,
result,
});
result
}
}
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
let mut block = [0; 3];
for axis in 0..3 {
let value = position[axis].floor();
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return None;
}
block[axis] = value as i32;
}
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
}
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
radius.is_finite()
&& radius >= 0.0
&& distance_squared.is_finite()
&& distance_squared > 0.0
&& pixel_focal_length.is_finite()
&& pixel_focal_length > 0.0
&& projected_limit.is_finite()
&& projected_limit * projected_limit < distance_squared
}
/// `ClientExplosionTracker.ExplosionInfo`.
#[derive(Clone, Copy, Debug)]
struct TrackedExplosion {
@@ -1551,7 +1657,7 @@ impl ParticleEngine {
for _ in 0..128 {
let r = &mut self.level_random;
let t = r.next_double();
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let position = [
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) + 0.5,
to[1] + t * (from[1] - to[1]) + r.next_double() - 0.5,
@@ -1579,7 +1685,7 @@ impl ParticleEngine {
for _ in 0..128 {
let r = &mut self.level_random;
let t = r.next_double();
let velocity = std::array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
let velocity = array::from_fn(|_| f64::from((r.next_float() - 0.5) * 0.2));
// Mth.lerp(t, to, from).
let position = [
to[0] + t * (from[0] - to[0]) + (r.next_double() - 0.5) * WIDTH * 2.0,
@@ -4391,8 +4497,7 @@ impl ParticleEngine {
.filter(|p| p.rgba[3] > 0.0)
.filter_map(|p| {
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
let at =
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
let at = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
})
.collect()
@@ -4407,11 +4512,70 @@ impl ParticleEngine {
partial: f32,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
self.render_inner::<false>(
camera,
yaw,
pitch,
partial,
0.0,
None,
&mut |_, _| false,
light_at,
out,
);
}
/// Render particles after rejecting view-frustum misses and any particles
/// the caller proves hidden behind opaque terrain.
pub fn render_culled(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
culling_enabled: bool,
frustum: Option<&crate::gfx::Frustum>,
mut occluded: impl FnMut([f64; 3], [f64; 3]) -> bool,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
if !culling_enabled {
self.render(camera, yaw, pitch, partial, light_at, out);
return;
}
self.render_inner::<true>(
camera,
yaw,
pitch,
partial,
pixel_focal_length,
frustum,
&mut occluded,
light_at,
out,
);
}
fn render_inner<const CULL: bool>(
&self,
camera: DVec3,
yaw: f32,
pitch: f32,
partial: f32,
pixel_focal_length: f32,
frustum: Option<&crate::gfx::Frustum>,
occluded: &mut dyn FnMut([f64; 3], [f64; 3]) -> bool,
light_at: &dyn Fn([i32; 3]) -> u8,
out: &mut Vec<Vertex>,
) {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
let mut section_cache =
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
for p in &self.particles {
if p.no_render {
continue;
@@ -4423,6 +4587,39 @@ impl ParticleEngine {
{
continue;
}
if CULL && matches!(p.behaviour, Behaviour::Shriek { delay } if delay > 0) {
continue;
}
let culled_geometry = if CULL {
let a = f64::from(partial);
let world = array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * a);
let center = (DVec3::from_array(world) - camera).as_vec3();
let size = p.quad_size(partial);
// Every generated quad vertex is sqrt(2) * size from its center,
// including the rotated two-quad particle types.
let radius = f64::from(size.abs()) * std::f64::consts::SQRT_2 + 0.05;
if let (Some(frustum), Some(section_cache)) = (frustum, section_cache.as_mut()) {
match section_cache.result(world, camera, radius, frustum) {
SectionFrustumResult::Outside => continue,
SectionFrustumResult::Intersects
if !frustum.intersects_sphere(center, radius as f32) =>
{
continue;
}
SectionFrustumResult::Inside | SectionFrustumResult::Intersects => {}
}
}
let distance_squared = f64::from(center.length_squared());
if below_half_pixel(radius, distance_squared, pixel_focal_length) {
continue;
}
if occluded(world.map(|v| v - radius), world.map(|v| v + radius)) {
continue;
}
Some((center, size))
} else {
None
};
// `SubmitNodeCollection.submitQuadParticleGroup`: the translucent
// layer draws after translucent terrain, so a crumb of stained
// glass cannot hide the glass behind it (`getLayer`).
@@ -4438,19 +4635,20 @@ impl ParticleEngine {
},
};
let Some(r) = rect else { continue };
let a = f64::from(partial);
let pos = DVec3::new(
p.previous[0] + (p.position[0] - p.previous[0]) * a,
p.previous[1] + (p.position[1] - p.previous[1]) * a,
p.previous[2] + (p.position[2] - p.previous[2]) * a,
) - camera;
let center = pos.as_vec3();
let (center, size) = culled_geometry.unwrap_or_else(|| {
let a = f64::from(partial);
let pos = DVec3::new(
p.previous[0] + (p.position[0] - p.previous[0]) * a,
p.previous[1] + (p.position[1] - p.previous[1]) * a,
p.previous[2] + (p.position[2] - p.previous[2]) * a,
) - camera;
(pos.as_vec3(), p.quad_size(partial))
});
let rotation = if p.roll != 0.0 {
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
} else {
base
};
let size = p.quad_size(partial);
let mut alpha = p.rgba[3];
// OverlayParticle.extract: the flash fades as it pulses.
if p.ty == ParticleType::Flash {
@@ -4464,7 +4662,7 @@ impl ParticleEngine {
match p.behaviour {
// ShriekParticle.extract: two tilted quads, fading out.
Behaviour::Shriek { delay } => {
if delay > 0 {
if !CULL && delay > 0 {
continue;
}
alpha = 1.0 - ((p.age as f32 + partial) / p.lifetime as f32).clamp(0.0, 1.0);
@@ -4507,7 +4705,7 @@ impl ParticleEngine {
// DustColorTransitionParticle.lerpColors.
Behaviour::Transition { from, to } => {
let t = (p.age as f32 + partial) / (p.lifetime as f32 + 1.0);
rgb = std::array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
rgb = array::from_fn(|i| from[i] + (to[i] - from[i]) * t);
}
_ => {}
}
@@ -4642,6 +4840,86 @@ mod tests {
assert_eq!(reads.get(), 3);
}
#[test]
fn subpixel_culling_uses_the_frame_projection_and_fails_open() {
assert!(below_half_pixel(0.01, 41.0_f64.powi(2), 1_000.0));
assert!(!below_half_pixel(0.01, 40.0_f64.powi(2), 1_000.0));
assert!(!below_half_pixel(0.01, 41.0_f64.powi(2), 0.0));
assert!(!below_half_pixel(0.01, f64::NAN, 1_000.0));
assert!(!below_half_pixel(f64::NAN, 41.0_f64.powi(2), 1_000.0));
for distance in [0.25_f32, 1.0, 8.0, 40.0, 128.0] {
for radius in [0.01, 0.05, 0.2] {
let center = FVec3::new(distance, distance * 0.3, -distance * 0.8);
let old = 2.0 * radius * 900.0 / f64::from(center.length()) < 0.5;
assert_eq!(
below_half_pixel(radius, f64::from(center.length_squared()), 900.0),
old
);
}
}
}
#[test]
fn disabled_culled_render_matches_the_regular_particle_path() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let sprite = "test:smoke".to_owned();
engine.atlas.insert(
sprite.clone(),
(
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
false,
),
);
let mut random = LegacyRandom::new(7);
let mut particle =
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
particle.sprite = Sprite::Named(sprite);
engine.particles.push(particle);
let light_at = |_: [i32; 3]| 0xf0;
let mut regular = Vec::new();
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
let mut disabled = Vec::new();
engine.render_culled(
DVec3::ZERO,
0.0,
0.0,
0.5,
900.0,
false,
None,
|_, _| panic!("disabled culling must not query particle visibility"),
&light_at,
&mut disabled,
);
let attributes = |vertices: &[Vertex]| {
vertices
.iter()
.map(|vertex| {
(
vertex.pos,
vertex.uv,
vertex.layer,
vertex.color,
vertex.light,
vertex.normal,
vertex.glint_uv,
)
})
.collect::<Vec<_>>()
};
assert_eq!(attributes(&disabled), attributes(&regular));
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
+39 -15
View File
@@ -144,9 +144,6 @@ pub struct Settings {
pub discrete_scroll: bool,
pub toggle_crouch: bool,
pub toggle_sprint: bool,
/// 3D skin layers (`threeDSkinLayers`): each painted texel of a player's
/// outer layer drawn as a slab (`crate::...`, Options → Skin).
pub three_d_skin_layers: bool,
pub auto_jump: bool,
pub language: String,
pub force_unicode: bool,
@@ -251,7 +248,6 @@ impl Default for Settings {
allow_cursor_changes: true,
toggle_crouch: false,
toggle_sprint: false,
three_d_skin_layers: false,
auto_jump: false,
language: "en_us".into(),
force_unicode: false,
@@ -341,23 +337,28 @@ impl Settings {
.parent()
.map(crate::gamepad::PadSettings::load)
.unwrap_or_default();
let extras = path
let extras_text = path
.parent()
.map(crate::extras::Extras::load)
.and_then(|dir| fs::read_to_string(dir.join(crate::extras::FILE)).ok())
.unwrap_or_default();
let mut extras = crate::extras::Extras::parse(&extras_text);
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
.unwrap_or_default();
let settings = match fs::read(path) {
Ok(bytes) => Self {
horizon,
controller,
extras,
mods,
..Self::parse(&String::from_utf8_lossy(&bytes))
},
Ok(bytes) => {
let text = String::from_utf8_lossy(&bytes);
extras.read_old_options(&extras_text, &text);
Self {
horizon,
controller,
extras,
mods,
..Self::parse(&text)
}
}
Err(_) => Self {
horizon,
controller,
@@ -463,7 +464,6 @@ impl Settings {
allow_cursor_changes: f.bool("allowCursorChanges"),
toggle_crouch: f.bool("toggleCrouch"),
toggle_sprint: f.bool("toggleSprint"),
three_d_skin_layers: f.bool("threeDSkinLayers"),
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
@@ -575,7 +575,6 @@ impl Settings {
put_b(&mut f, "allowCursorChanges", self.allow_cursor_changes);
put_b(&mut f, "toggleCrouch", self.toggle_crouch);
put_b(&mut f, "toggleSprint", self.toggle_sprint);
put_b(&mut f, "threeDSkinLayers", self.three_d_skin_layers);
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
@@ -852,6 +851,31 @@ mod tests {
);
}
#[test]
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
// #8091 wrote the switch into options.txt; a player who had it on keeps
// it, and the next options.txt is vanilla's again.
let dir = std::env::temp_dir().join(format!(
"horizon-skin-layers-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("options.txt"), "fov:0.5\nthreeDSkinLayers:true\n").unwrap();
let s = Settings::load(&dir.join("options.txt"));
assert!(s.extras.three_d_skin_layers);
assert_eq!(s.fov, 90.0);
assert!(!s.serialize().contains("threeDSkinLayers"));
assert!(crate::extras::Extras::parse(&s.extras.serialize()).three_d_skin_layers);
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
fs::write(dir.join(crate::extras::FILE), "threeDSkinLayers:false\n").unwrap();
assert!(
!Settings::load(&dir.join("options.txt"))
.extras
.three_d_skin_layers
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_option_set_keeps_horizon_files() {
let mut s = Settings::parse("fov:70\n");
s.mods.enabled.insert("zoom".into());
+73
View File
@@ -18,8 +18,15 @@ thread_local! {
static HERE: Cell<bool> = const { Cell::new(false) };
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
static DEPTH: Cell<u8> = const { Cell::new(0) };
/// What this thread's slowest steps were busy with since it last took them ([`note`]).
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
}
/// The shortest step a [`note`] keeps, in milliseconds.
pub const NOTE_MS: f32 = 10.0;
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
const NOTES_KEPT: usize = 3;
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Span {
@@ -83,6 +90,36 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
});
out
}
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
/// kept, and `what` is built only for a step that is.
pub fn note(ms: f32, what: impl FnOnce() -> String) {
if ms < NOTE_MS || !recording_here() {
return;
}
NOTES.with(|n| {
let mut n = n.borrow_mut();
if n.len() >= NOTES_KEPT {
let Some((i, _)) = n
.iter()
.enumerate()
.filter(|(_, (kept, _))| *kept < ms)
.min_by(|a, b| a.1.0.total_cmp(&b.1.0))
else {
return;
};
n.swap_remove(i);
}
n.push((ms, what()));
});
}
/// This thread's notes ([`note`]), slowest first; clears them.
pub fn take_notes() -> Vec<(f32, String)> {
let mut notes = NOTES.with(|n| std::mem::take(&mut *n.borrow_mut()));
notes.sort_by(|a, b| b.0.total_cmp(&a.0));
notes
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
pub struct Laps(Option<(Instant, Option<Duration>)>);
@@ -404,6 +441,41 @@ pub mod startup {
#[cfg(test)]
mod tests {
use super::Span;
use std::sync::{Mutex, MutexGuard};
/// Spans on for every thread ([`super::enable`]) is process-wide: a test that turns them on
/// would make another, run beside it, record what it expects left out. Those tests take
/// this lock.
fn spans_switch() -> MutexGuard<'static, ()> {
static SWITCH: Mutex<()> = Mutex::new(());
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
}
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
/// thread that records no spans leave none.
#[test]
fn notes_keep_the_slowest_steps() {
let _switch = spans_switch();
super::record_here(false);
super::note(500.0, || "unrecorded".into());
assert!(super::take_notes().is_empty());
super::record_here(true);
for (ms, what) in [
(12.0, "a"),
(900.0, "b"),
(3.0, "short"),
(40.0, "c"),
(11.0, "d"),
(60.0, "e"),
] {
super::note(ms, || what.into());
}
let notes: Vec<(f32, String)> = super::take_notes();
super::record_here(false);
let names: Vec<&str> = notes.iter().map(|(_, w)| w.as_str()).collect();
assert_eq!(names, ["b", "e", "c"]);
assert!(super::take_notes().is_empty(), "taking them clears them");
}
/// Spans of one name add up; a span the thread spent mostly waiting
/// shows its CPU time after the wall time, short ones are left out.
@@ -515,6 +587,7 @@ mod tests {
/// Laps record the steps between them while spans are on, and nothing while off.
#[test]
fn laps_time_consecutive_steps() {
let _switch = spans_switch();
let _ = super::take();
let mut off = super::laps();
off.lap("unseen");
@@ -1,7 +1,7 @@
//! Splitscreen's profile page (Horizon's Splitscreen setting): a joining player picks a profile,
//! or makes one, before entering P1's world, and a player in it switches to another from its
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
//! (kept in the game folder), with its own options. The
//! (instance-local offline names and the launcher's Microsoft accounts), with its own options. The
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
//! can't play (`refused`). Built from vanilla's widgets.
use crate::{
@@ -56,10 +56,12 @@ pub fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
pub struct SplitProfiles {
/// The game filled the page since it opened.
pub filled: bool,
/// The profiles; those playing now (none can be picked twice); and the one this player plays
/// as (switching; none while joining).
/// The profiles; those playing now (none can be picked twice); the signed-in Microsoft
/// accounts among them; and the one this player plays as (switching; none while joining).
pub names: Vec<String>,
pub taken: Vec<String>,
pub accounts: Vec<String>,
pub offline_accounts: Vec<String>,
pub current: Option<String>,
/// Why the last pick can't play (an online server, an expired sign-in), until the next.
pub refused: Option<String>,
@@ -127,7 +129,7 @@ impl SplitProfiles {
}
fn editable(&self, name: &str) -> bool {
!self.taken.contains(&name.to_owned())
!self.accounts.contains(&name.to_owned()) && !self.taken.contains(&name.to_owned())
}
fn buttons(&self, w: f32, h: f32) -> Vec<Button> {
@@ -179,24 +181,34 @@ impl SplitProfiles {
.skip(self.page() * SHOWN)
.take(SHOWN)
{
let microsoft = self.accounts.contains(name);
let mut label = name.clone();
if microsoft {
label.push_str(&if self.offline_accounts.contains(name) {
tr("horizon.splitscreen.profiles.offline", " (Offline)")
} else {
tr("horizon.splitscreen.profiles.microsoft", " (Microsoft)")
});
}
if self.current.as_ref() == Some(name) {
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
[x, y, 176.0, 20.0],
[x, y, if microsoft { 200.0 } else { 176.0 }, 20.0],
label,
!self.taken.contains(name),
Target::Profile(i),
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
if !microsoft {
buttons.push((
[x + 180.0, y, 20.0, 20.0],
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
}
}
let mut y = 52.0 + self.names.len().min(SHOWN) as f32 * 24.0;
if self.names.len() > SHOWN {
@@ -322,6 +334,19 @@ impl SplitProfiles {
for ([x, y, bw, bh], label, enabled, target) in &buttons {
let hover = ui::hovered(cursor, *x, *y, *bw, *bh);
let remove = matches!(target, Target::Remove(_));
if hover && let Target::Profile(i) = target {
let name = &self.names[*i];
if self.accounts.contains(name) {
tip = Some(if self.offline_accounts.contains(name) {
tr(
"horizon.splitscreen.profiles.sign_in",
"Sign in again in the launcher",
)
} else {
tr("horizon.splitscreen.profiles.online", "Microsoft online")
});
}
}
let mut text = Text::literal(if remove { "" } else { label });
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
@@ -584,14 +609,31 @@ mod tests {
names: (0..11).map(|i| format!("Player{i}")).collect(),
..Default::default()
};
p.accounts = vec!["Player10".into()];
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Next");
press(&mut p, &atlas, "Player10");
press(&mut p, &atlas, "Player10 (Microsoft)");
assert_eq!(p.take_choice(), Some(Some("Player10".into())));
press(&mut p, &atlas, "Previous");
assert!(p.buttons(427.0, 240.0).iter().any(|b| b.1 == "Player4"));
}
#[test]
fn unavailable_accounts_are_marked_and_cannot_be_forgotten() {
let mut p = SplitProfiles {
names: vec!["Cal".into()],
accounts: vec!["Cal".into()],
offline_accounts: vec!["Cal".into()],
..Default::default()
};
assert!(!p.editable("Cal"));
let buttons = p.buttons(427.0, 240.0);
assert!(buttons.iter().any(|b| b.1 == "Cal (Offline)" && b.2));
assert!(!buttons.iter().any(|b| b.1 == "Forget Cal"));
press(&mut p, &atlas(), "Cal (Offline)");
assert_eq!(p.take_choice(), Some(Some("Cal".into())));
}
#[test]
fn profile_names_are_player_names() {
assert!(valid_name("Steve_2"));
+155 -58
View File
@@ -8,6 +8,7 @@ pub struct SpriteAnimation {
pub layer: u32,
frames: Arc<Vec<Vec<u8>>>,
clip: Arc<AnimationClip>,
materials: [Option<Arc<Vec<Vec<u8>>>>; 2],
}
/// What an animated sprite shows: `frame`, mixed `millis` thousandths of the
/// way towards `next` (0 for a single frame).
@@ -18,6 +19,14 @@ pub struct FrameBlend {
pub millis: u32,
}
impl SpriteAnimation {
pub(crate) fn has_materials(&self) -> bool {
self.materials.iter().any(Option::is_some)
}
/// Material categories follow the selected colour frame, without colour interpolation.
pub(crate) fn material_pixels(&self, kind: usize, tick: u64) -> Option<&[u8]> {
let frames = self.materials[kind].as_ref()?;
Some(&frames[self.frame(tick) % frames.len()])
}
pub fn frame(&self, tick: u64) -> usize {
self.clip.sample(tick).frame as usize
}
@@ -62,6 +71,15 @@ impl SpriteAnimation {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct StillKey {
frame: u64,
pbr: u64,
side: u32,
settings: [u32; 2],
flags: [bool; 4],
}
#[derive(Default)]
pub struct Sprites {
/// Edge length of every array layer and the layers' current frames, set
@@ -79,9 +97,9 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their
/// frames at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<Vec<u8>>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
@@ -90,7 +108,7 @@ pub struct Sprites {
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
still: std::collections::HashMap<StillKey, u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -117,7 +135,7 @@ pub fn set_texture_budget(mib: u32) {
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// textures. Native material stages and terrain shader packs read them; without a pack they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
@@ -126,7 +144,7 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
@@ -144,30 +162,12 @@ fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
/// Reduce data maps by selecting whole texels: material IDs and sentinel bytes cannot be averaged.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
(max, resample(&frame, side, max))
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
@@ -280,14 +280,28 @@ impl Sprites {
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for animation in &mut self.animations {
animation.materials = self.pbr[animation.layer as usize].each_ref().map(|map| {
map.as_ref().map(|(from, frames)| {
Arc::new(
frames
.iter()
.map(|frame| resample(frame, *from, pbr_size))
.collect(),
)
})
});
}
for (layer, maps) in std::mem::take(&mut self.pbr).into_iter().enumerate() {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
Some((from, frames)) => {
let initial = self.native[layer].2 % frames.len();
let (from, pixels) =
shrink_map(frames[initial].clone(), from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
@@ -629,8 +643,10 @@ impl Sprites {
let pbr = match name.split_once(':') {
Some(_) if labpbr_maps() => {
let path = crate::atlas_sources::texture_path(root, name, "textures");
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count, animation.as_ref()))
}
None if labpbr_maps() && !name.starts_with("fusion/") => ["_n", "_s"]
.map(|suffix| pbr_map(&root.join(name), suffix, count, animation.as_ref())),
_ => [None, None],
};
let settings = crate::mipmap::Settings {
@@ -647,18 +663,20 @@ impl Sprites {
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
[
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
[settings.has_transparent, settings.item, opaque, translucent],
)
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
@@ -680,6 +698,7 @@ impl Sprites {
layer,
frames: frames.clone(),
clip: Arc::new(clip),
materials: [None, None],
});
}
self.mips.push(settings);
@@ -723,32 +742,64 @@ fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
/// Companion frames use the colour image's grid; a single-frame map stays static.
fn pbr_map(
path: &Path,
suffix: &str,
count: u32,
animation: Option<&AnimationMetadata>,
) -> Option<(u32, Vec<Vec<u8>>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
let (width, height, _) = read_rgba(path).ok()?;
let [frame_width, frame_height] = animation.map_or([width as i32, height as i32], |a| {
a.frame_size(width as i32, height as i32)
});
let (frame_width, frame_height) = (
u32::try_from(frame_width).ok()?,
u32::try_from(frame_height).ok()?,
);
if frame_width == 0 || frame_height == 0 {
return None;
}
let (columns, rows) = if count <= 1 || (map_width == frame_width && map_height == frame_height)
{
(1, 1)
} else if u64::from(map_width) * u64::from(height) == u64::from(map_height) * u64::from(width) {
(width / frame_width, height / frame_height)
} else if u64::from(map_width) * u64::from(frame_height)
== u64::from(map_height) * u64::from(frame_width)
{
(1, 1)
} else {
return None;
};
if columns == 0 || rows == 0 || map_width % columns != 0 || map_height % rows != 0 {
return None;
}
let (w, h) = (map_width / columns, map_height / rows);
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset =
(((y + row * h / side) * map_width + x + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
resample(&frame, side, output_side)
})
.collect();
Some((output_side, frames))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -779,6 +830,7 @@ pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> S
layer,
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
frames: Arc::new(frames),
materials: [None, None],
}
}
@@ -804,8 +856,8 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
@@ -813,14 +865,14 @@ mod tests {
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
assert_eq!(shrink_map(frame, 2, 1), (1, vec![200, 100, 0, 255]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[Some((16, vec![[9, 8, 7, 6].repeat(16 * 16)])), None],
[None, None],
],
..Default::default()
@@ -833,6 +885,50 @@ mod tests {
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn companion_grid_and_static_map_follow_colour_frame_selection() {
let root =
std::env::temp_dir().join(format!("horizon-companion-grid-{}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let write = |name: &str, width, height, pixels: &[u8]| {
let mut encoder =
png::Encoder::new(File::create(root.join(name)).unwrap(), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(pixels)
.unwrap();
};
write("grid.png", 4, 4, &[200, 200, 200, 255].repeat(16));
let maps: Vec<u8> = (0..16)
.flat_map(|i| {
let cell = (i / 8) * 2 + (i % 4) / 2;
[10 + cell as u8, 230, 65, 255]
})
.collect();
write("grid_s.png", 4, 4, &maps);
write("grid_n.png", 2, 2, &[128, 128, 255, 255].repeat(4));
let metadata =
r#"{"width":2,"height":2,"frametime":2,"interpolate":true,"frames":[3,1,2,0]}"#;
let animation = AnimationMetadata::from_json(metadata).unwrap();
let (side, frames) = pbr_map(&root.join("grid.png"), "_s", 4, Some(&animation)).unwrap();
assert_eq!(side, 2);
assert_eq!(
frames.iter().map(|f| f[0]).collect::<Vec<_>>(),
[10, 11, 12, 13]
);
let (_, normals) = pbr_map(&root.join("grid.png"), "_n", 4, Some(&animation)).unwrap();
assert_eq!(normals.len(), 1);
let mut sprite = test_frames(0, vec![vec![0; 16]; 4], metadata);
sprite.materials = [Some(Arc::new(normals)), Some(Arc::new(frames))];
for (tick, expected) in [(0, 13), (1, 13), (2, 11), (4, 12), (6, 10)] {
assert_eq!(sprite.material_pixels(1, tick).unwrap()[0], expected);
assert_eq!(&sprite.material_pixels(0, tick).unwrap()[..4], &FLAT_NORMAL);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
@@ -1273,6 +1369,7 @@ mod tests {
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
let animation = SpriteAnimation {
layer: 0,
materials: [None, None],
frames: Arc::new(vec![vec![], vec![], vec![]]),
clip: Arc::new(AnimationMetadata::from_json(r#"{"frames":[{"index":2,"time":8},{"index":1,"time":3},{"index":1,"time":2},0]}"#).unwrap().clip(3).unwrap().unwrap()),
};
@@ -1224,6 +1224,14 @@ impl Streaming {
world.layout().sections() as i32,
);
}
// `ChunkTaskPriorityQueue.release` for each of them (`ChunkTaskDispatcher`'s
// `release(col, true)` from the chunk's unload): the jobs still queued for a
// column go with it instead of running for a level they no longer belong to.
if !gone.is_empty()
&& let Ok(mut queue) = self.shared.queue.lock()
{
release_columns(&mut queue, gone);
}
let mut chunks = Vec::with_capacity(gone.len());
for &(x, z) in gone {
self.lit.remove(&(x, z));
@@ -2879,6 +2887,25 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
}
*queue = BinaryHeap::from(jobs);
}
/// `ChunkTaskPriorityQueue.release(pos, clearQueue=true)`: the jobs queued for the
/// `gone` columns are dropped, so a view that moved or shrank does not wait behind the
/// generation of the chunks it left. A job without a column (a save, meshes to free)
/// stays, as vanilla keeps the tasks of a chunk that is still loaded. Returns how many
/// jobs went.
fn release_columns(queue: &mut BinaryHeap<Queued>, gone: &FxHashSet<(i32, i32)>) -> usize {
if queue.is_empty() || gone.is_empty() {
return 0;
}
let queued = std::mem::take(queue).into_vec();
let before = queued.len();
let kept: Vec<Queued> = queued
.into_iter()
.filter(|job| !job.at.is_some_and(|at| gone.contains(&at)))
.collect();
let released = before - kept.len();
*queue = BinaryHeap::from(kept);
released
}
/// Whether the 3x3 a light job for (x, z) reads is all there: every
/// neighbour a finished column, or a decorated proto chunk past the area
/// (the last FULL ring lights against neighbours at FEATURES, as
@@ -3310,6 +3337,59 @@ mod tests {
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
/// `ChunkTaskPriorityQueue.release`: a column that unloaded keeps no queued job, so
/// a teleport or a smaller render distance does not spend its workers on the world
/// it left; the other columns' jobs and the jobs without a column (saves, meshes to
/// free) stay, in the same order.
#[test]
fn a_released_column_keeps_no_queued_job() {
let generate = |x, z, priority, order| Queued {
priority,
at: Some((x, z)),
bias: 0,
order,
job: Job::Generate(x, z),
};
let save = |order| Queued {
priority: i64::MIN,
at: None,
bias: 0,
order,
job: Job::Save(Vec::new()),
};
let queued = || {
BinaryHeap::from(vec![
generate(0, 0, 0, 1),
generate(0, 0, 4, 2),
generate(1, 0, 8, 3),
generate(9, 9, 12, 4),
save(5),
])
};
let pops = |mut queue: BinaryHeap<Queued>| {
std::iter::from_fn(move || queue.pop())
.map(|q| q.at)
.collect::<Vec<_>>()
};
// Nothing goes for a column that is still loaded, or for nothing at all.
let mut queue = queued();
assert_eq!(release_columns(&mut queue, &FxHashSet::default()), 0);
assert_eq!(
release_columns(&mut queue, &[(20, 20)].into_iter().collect()),
0
);
assert_eq!(queue.len(), 5);
// Both of (0, 0)'s jobs go, its neighbour's and the save stay.
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
assert_eq!(release_columns(&mut queue, &gone), 2);
assert_eq!(pops(queue), [None, Some((1, 0)), Some((9, 9))]);
// Every column leaving at once: only the jobs without one are left.
let mut queue = queued();
let all: FxHashSet<(i32, i32)> = [(0, 0), (1, 0), (9, 9)].into_iter().collect();
assert_eq!(release_columns(&mut queue, &all), 4);
assert_eq!(pops(queue), [None]);
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
+202 -29
View File
@@ -32,13 +32,14 @@ struct Globals {
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
// in texels (vanilla's TextureSize).
// in texels (vanilla's TextureSize), z: the mip level at which a sprite is one texel when
// the views reach it, else 0 (`rotated_grid`).
filtering: vec4<f32>,
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@@ -124,12 +125,34 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
// Only the side of the mix that has weight is sampled: near texels (blend 0) take
// sample_nearest's one tap alone, far ones (blend 1) the rotated grid alone.
if blend <= 0.0 {
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled);
}
let rgss = rotated_grid(uv, layer, pixel, du, dv);
if blend >= 1.0 {
return rgss;
}
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
}
fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
dv: vec2<f32>) -> vec4<f32> {
let min_pixel = min(pixel.x, pixel.y);
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
// From this level on the sprite is a single texel (0: the views stop before one is), and
// the sampler clamps any level past it to it: there every tap reads the same value, so
// one tap gives the four's mean. Distant tiles sit there.
let single = globals.filtering.z;
if single > 0.0 && low >= single {
return sprite_level(uv, layer, low);
}
let high_single = single > 0.0 && low + 1.0 >= single;
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
@@ -137,10 +160,14 @@ fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: v
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
sum_high += sprite_level(p, layer, low + 1.0);
if !high_single {
sum_high += sprite_level(p, layer, low + 1.0);
}
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled), rgss, blend);
if high_single {
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
}
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
}
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
@@ -148,10 +175,13 @@ fn tile_uv(uv: vec2<f32>) -> vec2<f32> {
return uv - max(ceil(uv) - 1.0, vec2(0.0));
}
fn sprite(tiled: vec2<f32>, layer: u32) -> vec4<f32> {
// terrain.fsh's two sampling paths: sampleRGSS for RGSS, sampleNearest otherwise.
// The gradients come from the unwrapped UV, so the mip level holds across block seams.
let du = dpdx(tiled);
let dv = dpdy(tiled);
return sprite_at(tiled, layer, dpdx(tiled), dpdy(tiled));
}
// `sprite` with the unwrapped UV's screen gradients given: the visibility buffer's resolve
// (visbuffer.wgsl) works them out from the quad, as its neighbours may be other quads.
fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4<f32> {
// terrain.fsh's two sampling paths: sampleRGSS for RGSS, sampleNearest otherwise.
let uv = tile_uv(tiled);
// The wrap shifts a face's UVs by whole blocks, so half of one tells a tile's face from a
// block's (whose UVs stay inside its sprite).
@@ -320,8 +350,10 @@ fn fade_code(w: f32) -> u32 {
// (`mesher::Part`): first the quads with no cutout that are not fading, through
// `fs_opaque`, which never discards, so a tiled GPU (Apple's) keeps hiding the pixels
// behind them; then the rest through `fs_solid`. One discard anywhere in a pipeline's shader turned that hiding off for
// all the terrain it drew. A quad the pass leaves out is decided before any of the
// vertex's work and lies outside the clip volume: it makes no pixel.
// all the terrain it drew. The draw lists give each pass only its own part; a mesh that
// was not sorted (`Sides::unsorted`) gives both passes all of its quads, so each pass
// still leaves out the other's: decided before any of the vertex's work, outside the
// clip volume, it makes no pixel.
fn opaque_quad(layer: u32, w: f32) -> bool {
return (layer & 0x8000u) != 0u && fade_code(w) == 0u;
}
@@ -366,6 +398,134 @@ fn left_out() -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
// (`TerrainVertex`, six words, as `TERRAIN_ATTRIBUTES` reads them), from PACKED_BASE on a
// corner of the quad whose record is at that slot (`PACKED_BASE + 4 * slot + corner`).
// Either way they come out as the six words of the vertex, so the work after is the same.
// Binding 19: visbuffer.wgsl, in the same module, takes 2 to 18 of its own group 1.
@group(1) @binding(19) var<storage, read> arena_page: array<vec2<u32>>;
const PACKED_BASE: u32 = 0x10000000u;
// A vertex's words: position bits, then UV, layer with light, colour.
struct ArenaCorner {
p: vec3<u32>,
r: vec3<u32>,
}
fn arena_vertex(v: u32) -> ArenaCorner {
let a = arena_page[v * 3u];
let b = arena_page[v * 3u + 1u];
let c = arena_page[v * 3u + 2u];
return ArenaCorner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y));
}
// `packed::packed_vertex`: corner `corner` of the record whose words are lo (0 to 2) and
// hi (3 to 5).
fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let orient = (hi.z >> 12u) & 0x3ffffu;
let block = vec3(orient & 15u, (orient >> 4u) & 15u, (orient >> 8u) & 15u);
let axis = (orient >> 12u) & 3u;
let side = (orient >> 14u) & 1u;
let start = (orient >> 15u) & 3u;
let dir = (orient >> 17u) & 1u;
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
var pos = block;
pos[axis] = block[axis] + side;
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
let u = (lo.z >> (6u * us)) & 63u;
let v = (lo.z >> (12u + 6u * vs)) & 63u;
var light = hi.z;
if corner == 0u {
light = hi.x >> 16u;
} else if corner == 1u {
light = hi.y;
} else if corner == 2u {
light = hi.y >> 12u;
}
light &= 0xfffu;
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
// A solid draw's vertex `vertex`, and the slot its block is found by (`block_slots`, 256
// slots a block): the vertex's own, or its quad's record's. `packed`: it has a record.
struct SectionCorner {
c: ArenaCorner,
slot: u32,
packed: bool,
}
fn section_corner(vertex: u32) -> SectionCorner {
if vertex < PACKED_BASE {
return SectionCorner(arena_vertex(vertex), vertex, false);
}
let slot = (vertex - PACKED_BASE) >> 2u;
let corner = vertex & 3u;
let a = arena_page[slot * 3u];
let b = arena_page[slot * 3u + 1u];
let c = arena_page[slot * 3u + 2u];
// `packed::FULL`: the quad's four vertices, word 0 slots on.
if (c.y & 0x80000000u) != 0u {
return SectionCorner(arena_vertex(slot + a.x + corner), slot, true);
}
return SectionCorner(packed_corner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y), corner), slot,
true);
}
// The colour as the vertex fetch converts a Unorm8x4 attribute, bit for bit (Apple's M1:
// times 1/255; `unpack4x8unorm` and the correctly rounded c / 255 each differ from it for
// some bytes, and moved a few pixels' colour or alpha cutout).
fn unorm8x4(c: u32) -> vec4<f32> {
return vec4<f32>(vec4(c & 255u, (c >> 8u) & 255u, (c >> 16u) & 255u, c >> 24u))
* (1.0 / 255.0);
}
fn corner_layer(c: ArenaCorner) -> u32 {
return c.r.y & 0xffffu;
}
fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
let uv = vec2(c.r.x & 0xffffu, c.r.x >> 16u);
let light = vec2((c.r.y >> 16u) & 0xffu, c.r.y >> 24u);
return terrain_vertex(bitcast<vec3<f32>>(c.p), terrain_uv(uv, origin.w), corner_layer(c),
unorm8x4(c.r.z), light, origin.xyz, origin.w);
}
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
return corner_vertex(section_corner(vertex).c, origin);
}
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
let s = section_corner(vertex);
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
@location(5) origin: vec4<f32>) -> TerrainOut {
let s = section_corner(vertex);
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
return corner_vertex(s.c, origin);
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
@@ -448,7 +608,9 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
if globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
@@ -463,7 +625,9 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -475,6 +639,11 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
}
// The rest of a solid pixel's colour once it is kept: emission and fog.
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
return solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv));
}
// `solid_finish` with the UV's screen gradients given (`sprite_at`).
fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<f32>,
dv: vec2<f32>) -> vec4<f32> {
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's pixel
// shines, at which tier (mesher::hdr_tier) and how much (hdr_mark), by the
// texel's own brightness, so its light level and face shading don't change
@@ -482,27 +651,31 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
// parts, or all of it. Nothing else reads the frame's alpha.
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
// fully fogged mountain would stand out from the fog behind it.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
}
}
}
if in.emissive_layer != 0xffffffffu {
// The high bit: an OptiFine overlay, drawn cutout (opaque where alpha >= 0.1).
var mask = sprite(in.uv, in.emissive_layer & 0x7fffffffu);
var mask = sprite_at(in.uv, in.emissive_layer & 0x7fffffffu, du, dv);
if (in.emissive_layer & 0x80000000u) != 0u {
mask.a = select(0.0, 1.0, mask.a >= 0.1);
}
@@ -0,0 +1,427 @@
// The visibility buffer for solid terrain (gfx/visbuffer.rs, `HORIZON_VISBUFFER=1`), after
// terrain.wgsl in one module. Pass 1 rasterises the flat solid draws writing depth and, per
// pixel, the quad it shows: its arena page and its quad in that page (`vis_id`). Pass 2, one draw for up to 16 pages, runs the shading of `fs_opaque`/`solid_color`
// once per pixel from that ID: it reads the quad's corners from its page as the vertex shaders
// do, runs `terrain_vertex` on the
// triangle the pixel falls in, interpolates its outputs with perspective-correct barycentrics
// and works out the screen gradients `dpdx`/`dpdy` would give, so the texel, mip level, light
// and fog come out as in the normal path.
// Bit 31: terrain is here; bit 30: the quad has a record (`mesher/packed.rs`); bits 24 to 29:
// the arena page (`visbuffer::MAX_PAGES`); bits 0 to 23: the quad's record's slot in the page,
// else its first vertex over 4 (a page holds at most `arena::PAGE_BYTES` / 24 bytes =
// 11 184 810 slots, under 2^24).
fn vis_id(page: u32, s: SectionCorner) -> u32 {
let at = 0x80000000u | ((page & 0x3fu) << 24u);
if s.packed {
return at | 0x40000000u | s.slot;
}
return at | (s.slot >> 2u);
}
// `sprite_at` (terrain.wgsl's `sample_rgss` over `sample_nearest`), sampling only what its
// blends keep: where RGSS has not faded in (the texels span two pixels or more) the nearest
// texel alone, where it has fully, RGSS alone, and its upper mip level only where it blends one
// in. A blend of 0 or 1 gives exactly the one side, so the result is `sprite_at`'s; near
// faces take one tap instead of nine.
fn vis_sprite(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4<f32> {
let uv = tile_uv(tiled);
let tiled_face = any(tiled - uv >= vec2(0.5));
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if u32(globals.filtering.x) != 1u {
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
var nearest = vec4(0.0);
if blend < 1.0 {
nearest = sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
}
if blend <= 0.0 {
return nearest;
}
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
for (var i = 0; i < 4; i++) {
sum_low += sprite_level(uv + offsets[i] * pixel, layer, low);
}
var rgss = sum_low * 0.25;
if mip_blend > 0.0 {
var sum_high = vec4(0.0);
for (var i = 0; i < 4; i++) {
sum_high += sprite_level(uv + offsets[i] * pixel, layer, low + 1.0);
}
rgss = mix(rgss, sum_high * 0.25, mip_blend);
}
return mix(nearest, rgss, blend);
}
struct VisIdOut {
@builtin(position) clip: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
}
// The quads with no cutout that are not fading (`opaque_quad`): no discard, so a tiled GPU
// keeps hiding what lies behind them. `page`: the draw's instance, its arena page
// (`Flat::draw_paged`).
@vertex fn vs_vis_opaque(
@builtin(vertex_index) vertex: u32, @builtin(instance_index) page: u32
) -> VisIdOut {
var out: VisIdOut;
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
if !opaque_quad(corner_layer(s.c), origin.w) {
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
let position = bitcast<vec3<f32>>(s.c.p);
out.clip = fade_depth(globals.view_proj * vec4(position + origin.xyz, 1.0), origin.w);
out.id = vis_id(page, s);
return out;
}
@fragment fn fs_vis_id(in: VisIdOut) -> @location(0) u32 {
return in.id;
}
struct VisCutoutOut {
@builtin(position) clip: vec4<f32>,
@location(0) @interpolate(flat) id: u32,
@location(1) uv: vec2<f32>,
@location(2) @interpolate(flat) layer: u32,
// The vertex colour's alpha (`terrain_vertex`'s `color.a`: the lightmap's).
@location(3) alpha: f32,
@location(4) @interpolate(flat) fade: u32,
@location(5) local: vec3<f32>,
}
// The other quads: `solid_color`'s fade dither and alpha cutout, with no shading.
@vertex fn vs_vis_cutout(
@builtin(vertex_index) vertex: u32, @builtin(instance_index) page: u32
) -> VisCutoutOut {
var out: VisCutoutOut;
let s = section_corner(vertex);
let origin = slot_origins[block_slots[s.slot >> 8u]];
let layer = corner_layer(s.c);
if opaque_quad(layer, origin.w) {
out.clip = vec4(2.0, 2.0, 2.0, 1.0);
return out;
}
let position = bitcast<vec3<f32>>(s.c.p);
let uv = vec2(s.c.r.x & 0xffffu, s.c.r.x >> 16u);
let light = vec2((s.c.r.y >> 16u) & 0xffu, s.c.r.y >> 24u);
let pos = position + origin.xyz;
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), origin.w);
out.id = vis_id(page, s);
out.uv = terrain_uv(uv, origin.w);
out.layer = layer;
let block = max(f32(light.y), dynamic_block_light(pos));
out.alpha = sample_lightmap(f32(light.x) / 16.0, block / 16.0).a;
out.fade = fade_code(origin.w);
out.local = position;
return out;
}
@fragment fn fs_vis_cutout(in: VisCutoutOut) -> @location(0) u32 {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
let alpha = vis_sprite(in.uv, in.layer & 0x7fffu, dpdx(in.uv), dpdy(in.uv)).a * in.alpha;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return in.id;
}
// Pass 2. Up to 16 arena pages (mesher.rs's TerrainVertex, 24 bytes: position, UV as two u16,
// layer and light as u16 each, RGBA8 colour), a quad's 96 bytes as six vec4s; at bindings 0 and
// 1 the block tables of all pages, page after page, `VIS_BLOCKS_PER_PAGE` entries each
// (`Visbuffer::resolve` copies them from the flat draws' own), and the origins by section id
// (terrain.wgsl's flat draws); pass 1's IDs.
override VIS_BLOCKS_PER_PAGE: u32 = 1u;
@group(1) @binding(2) var<storage, read> vis_page_0: array<vec4<u32>>;
@group(1) @binding(3) var<storage, read> vis_page_1: array<vec4<u32>>;
@group(1) @binding(4) var<storage, read> vis_page_2: array<vec4<u32>>;
@group(1) @binding(5) var<storage, read> vis_page_3: array<vec4<u32>>;
@group(1) @binding(6) var<storage, read> vis_page_4: array<vec4<u32>>;
@group(1) @binding(7) var<storage, read> vis_page_5: array<vec4<u32>>;
@group(1) @binding(8) var<storage, read> vis_page_6: array<vec4<u32>>;
@group(1) @binding(9) var<storage, read> vis_page_7: array<vec4<u32>>;
@group(1) @binding(10) var<storage, read> vis_page_8: array<vec4<u32>>;
@group(1) @binding(11) var<storage, read> vis_page_9: array<vec4<u32>>;
@group(1) @binding(12) var<storage, read> vis_page_10: array<vec4<u32>>;
@group(1) @binding(13) var<storage, read> vis_page_11: array<vec4<u32>>;
@group(1) @binding(14) var<storage, read> vis_page_12: array<vec4<u32>>;
@group(1) @binding(15) var<storage, read> vis_page_13: array<vec4<u32>>;
@group(1) @binding(16) var<storage, read> vis_page_14: array<vec4<u32>>;
@group(1) @binding(17) var<storage, read> vis_page_15: array<vec4<u32>>;
@group(1) @binding(18) var vis_ids: texture_2d<u32>;
// A quad's 96 bytes, six 16-byte words from `i` on, of the page in `slot`: one switch for
// all of them, so a thread group whose pixels show several pages walks its cases once.
struct VisQuad {
q: array<vec4<u32>, 6>,
}
fn vis_quad(slot: u32, i: u32) -> VisQuad {
switch slot {
case 0u: {
return VisQuad(array(vis_page_0[i], vis_page_0[i + 1u], vis_page_0[i + 2u],
vis_page_0[i + 3u], vis_page_0[i + 4u], vis_page_0[i + 5u]));
}
case 1u: {
return VisQuad(array(vis_page_1[i], vis_page_1[i + 1u], vis_page_1[i + 2u],
vis_page_1[i + 3u], vis_page_1[i + 4u], vis_page_1[i + 5u]));
}
case 2u: {
return VisQuad(array(vis_page_2[i], vis_page_2[i + 1u], vis_page_2[i + 2u],
vis_page_2[i + 3u], vis_page_2[i + 4u], vis_page_2[i + 5u]));
}
case 3u: {
return VisQuad(array(vis_page_3[i], vis_page_3[i + 1u], vis_page_3[i + 2u],
vis_page_3[i + 3u], vis_page_3[i + 4u], vis_page_3[i + 5u]));
}
case 4u: {
return VisQuad(array(vis_page_4[i], vis_page_4[i + 1u], vis_page_4[i + 2u],
vis_page_4[i + 3u], vis_page_4[i + 4u], vis_page_4[i + 5u]));
}
case 5u: {
return VisQuad(array(vis_page_5[i], vis_page_5[i + 1u], vis_page_5[i + 2u],
vis_page_5[i + 3u], vis_page_5[i + 4u], vis_page_5[i + 5u]));
}
case 6u: {
return VisQuad(array(vis_page_6[i], vis_page_6[i + 1u], vis_page_6[i + 2u],
vis_page_6[i + 3u], vis_page_6[i + 4u], vis_page_6[i + 5u]));
}
case 7u: {
return VisQuad(array(vis_page_7[i], vis_page_7[i + 1u], vis_page_7[i + 2u],
vis_page_7[i + 3u], vis_page_7[i + 4u], vis_page_7[i + 5u]));
}
case 8u: {
return VisQuad(array(vis_page_8[i], vis_page_8[i + 1u], vis_page_8[i + 2u],
vis_page_8[i + 3u], vis_page_8[i + 4u], vis_page_8[i + 5u]));
}
case 9u: {
return VisQuad(array(vis_page_9[i], vis_page_9[i + 1u], vis_page_9[i + 2u],
vis_page_9[i + 3u], vis_page_9[i + 4u], vis_page_9[i + 5u]));
}
case 10u: {
return VisQuad(array(vis_page_10[i], vis_page_10[i + 1u], vis_page_10[i + 2u],
vis_page_10[i + 3u], vis_page_10[i + 4u], vis_page_10[i + 5u]));
}
case 11u: {
return VisQuad(array(vis_page_11[i], vis_page_11[i + 1u], vis_page_11[i + 2u],
vis_page_11[i + 3u], vis_page_11[i + 4u], vis_page_11[i + 5u]));
}
case 12u: {
return VisQuad(array(vis_page_12[i], vis_page_12[i + 1u], vis_page_12[i + 2u],
vis_page_12[i + 3u], vis_page_12[i + 4u], vis_page_12[i + 5u]));
}
case 13u: {
return VisQuad(array(vis_page_13[i], vis_page_13[i + 1u], vis_page_13[i + 2u],
vis_page_13[i + 3u], vis_page_13[i + 4u], vis_page_13[i + 5u]));
}
case 14u: {
return VisQuad(array(vis_page_14[i], vis_page_14[i + 1u], vis_page_14[i + 2u],
vis_page_14[i + 3u], vis_page_14[i + 4u], vis_page_14[i + 5u]));
}
default: {
return VisQuad(array(vis_page_15[i], vis_page_15[i + 1u], vis_page_15[i + 2u],
vis_page_15[i + 3u], vis_page_15[i + 4u], vis_page_15[i + 5u]));
}
}
}
struct VisResolveOut {
@builtin(position) clip: vec4<f32>,
// The group of 16 pages this draw resolves: its instance (`Visbuffer::resolve`).
@location(0) @interpolate(flat) chunk: u32,
}
@vertex fn vs_vis_resolve(@builtin(vertex_index) i: u32,
@builtin(instance_index) chunk: u32) -> VisResolveOut {
let p = vec2(f32(i32(i & 1u) * 4 - 1), f32(i32(i >> 1u) * 4 - 1));
var out: VisResolveOut;
out.clip = vec4(p, 0.0, 1.0);
out.chunk = chunk;
return out;
}
// A framebuffer position's NDC (the world pass draws to the whole target).
fn vis_ndc(at: vec2<f32>, size: vec2<f32>) -> vec2<f32> {
return vec2(at.x / size.x * 2.0 - 1.0, 1.0 - at.y / size.y * 2.0);
}
// Perspective-correct barycentrics of the point at NDC `p` on the triangle with clip-space
// corners a, b, c (x, y, w): homogeneous 2D rasterisation (Olano and Greer). With M the matrix
// of the corners as columns, the weights are M^-1 (p, 1) normalised, and the rows of M^-1 are
// the corners' cross products over the determinant, which the normalisation cancels. Corners
// behind the camera need no clipping. The rows are given (`vis_rows`), as three points share
// them.
fn vis_bary(rows: mat3x3<f32>, p: vec2<f32>) -> vec3<f32> {
let l = vec3(p, 1.0) * rows;
let sum = l.x + l.y + l.z;
if abs(sum) < 1e-30 {
return vec3(-1.0);
}
return l / sum;
}
fn vis_rows(a: vec3<f32>, b: vec3<f32>, c: vec3<f32>) -> mat3x3<f32> {
return mat3x3(cross(b, c), cross(c, a), cross(a, b));
}
// A corner's `terrain_vertex` from its six words: position, UV, layer and light, colour (the
// vertex attributes `TERRAIN_ATTRIBUTES` in gfx.rs give: Float32x3, Uint16x2, Uint16, Uint8x2
// with sky in the low byte, Unorm8x4).
fn vis_corner(position: vec3<u32>, rest: vec3<u32>, origin: vec4<f32>) -> TerrainOut {
let uv = vec2(rest.x & 0xffffu, rest.x >> 16u);
let light = vec2((rest.y >> 16u) & 0xffu, rest.y >> 24u);
return terrain_vertex(bitcast<vec3<f32>>(position), terrain_uv(uv, origin.w),
rest.y & 0xffffu, unpack4x8unorm(rest.z), light, origin.xyz, origin.w);
}
@fragment fn fs_vis_resolve(in: VisResolveOut) -> @location(0) vec4<f32> {
let pixel = vec2<i32>(floor(in.clip.xy));
let id = textureLoad(vis_ids, pixel, 0).x;
let page = (id >> 24u) & 0x3fu;
if (id & 0x80000000u) == 0u || page / 16u != in.chunk {
discard;
}
let slot = page % 16u;
let index = id & 0xffffffu;
// The quad's first vertex (four of them from a multiple of 4) or its record.
var first = 4u * index;
var record = false;
var lo = vec3(0u);
var hi = vec3(0u);
if (id & 0x40000000u) != 0u {
first = index;
// The record's six words from word 6 * index: two 16-byte words from the one it
// starts in, at its start or its middle.
let w = vis_quad(slot, (6u * index) >> 2u).q;
let middle = ((6u * index) & 3u) != 0u;
lo = select(w[0].xyz, vec3(w[0].zw, w[1].x), middle);
hi = select(vec3(w[0].w, w[1].xy), w[1].yzw, middle);
record = (hi.z & 0x80000000u) == 0u;
if !record {
// `packed::FULL`: its four vertices, word 0 slots on.
first = index + lo.x;
}
}
// The quad's block (256 slots: 64 quads of vertices, 256 records) names its section, as
// for `vs_section_flat`.
let block = select(index >> 6u, index >> 8u, (id & 0x40000000u) != 0u);
let origin = slot_origins[block_slots[page * VIS_BLOCKS_PER_PAGE + block]];
var p0: vec3<u32>;
var r0: vec3<u32>;
var p1: vec3<u32>;
var r1: vec3<u32>;
var p2: vec3<u32>;
var r2: vec3<u32>;
var p3: vec3<u32>;
var r3: vec3<u32>;
if record {
let c0 = packed_corner(lo, hi, 0u);
let c1 = packed_corner(lo, hi, 1u);
let c2 = packed_corner(lo, hi, 2u);
let c3 = packed_corner(lo, hi, 3u);
p0 = c0.p;
r0 = c0.r;
p1 = c1.p;
r1 = c1.r;
p2 = c2.p;
r2 = c2.r;
p3 = c3.p;
r3 = c3.r;
} else {
// Corner k is words 6k to 6k + 5 of the quad's 24.
let words = vis_quad(slot, 6u * (first >> 2u)).q;
let q0 = words[0];
let q1 = words[1];
let q2 = words[2];
let q3 = words[3];
let q4 = words[4];
let q5 = words[5];
p0 = q0.xyz;
r0 = vec3(q0.w, q1.x, q1.y);
p1 = vec3(q1.z, q1.w, q2.x);
r1 = q2.yzw;
p2 = q3.xyz;
r2 = vec3(q3.w, q4.x, q4.y);
p3 = vec3(q4.z, q4.w, q5.x);
r3 = q5.yzw;
}
let c0 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p0) + origin.xyz, 1.0)).xyw;
let c1 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p1) + origin.xyz, 1.0)).xyw;
let c2 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p2) + origin.xyz, 1.0)).xyw;
let c3 = (globals.view_proj * vec4(bitcast<vec3<f32>>(p3) + origin.xyz, 1.0)).xyw;
// The quad's triangles are 0 1 2 and 0 2 3 (`Renderer::quad_indices`, flatten.wgsl),
// either side of the diagonal 0 2. Corner 1's weight in 0 1 2 is `vis_bary`'s row
// cross(c2, c0) over the sum of the rows, whose sign is that of the triangle's determinant
// (the sum is one over the point's w), dot(c1, cross(c2, c0)): the pixel lies in 0 1 2 when
// the two agree. A degenerate 0 1 2 (no area, no pixels) leaves it to 0 2 3.
let size = vec2<f32>(textureDimensions(vis_ids));
let centre = in.clip.xy;
let p = vis_ndc(centre, size);
let diagonal = cross(c2, c0);
let det = dot(c1, diagonal);
let second = dot(vec3(p, 1.0), diagonal) * det < 0.0 || det == 0.0;
let rows = vis_rows(c0, select(c1, c2, second), select(c2, c3, second));
let b = vis_bary(rows, p);
// dpdxFine and dpdyFine: the difference to the other pixel of this one's 2x2 quad in its
// row and column, odd minus even, over the same triangle's plane.
let odd = vec2<f32>(pixel & vec2(1));
let toward = vec2(1.0) - 2.0 * odd;
let bx = vis_bary(rows, vis_ndc(centre + vec2(toward.x, 0.0), size));
let by = vis_bary(rows, vis_ndc(centre + vec2(0.0, toward.y), size));
// `terrain_vertex` on the triangle's corners, one at a time into the sums, so that only
// one corner's outputs are held at once; the first is the provoking one, whose flat
// outputs (layer, emission, fade, emissive layer) the rasteriser keeps.
var t = vis_corner(p0, r0, origin);
let uv0 = t.uv;
t.uv = b.x * uv0;
var uv_x = bx.x * uv0;
var uv_y = by.x * uv0;
t.color = b.x * t.color;
t.spherical = b.x * t.spherical;
t.cylindrical = b.x * t.cylindrical;
t.visibility = b.x * t.visibility;
let t1 = vis_corner(select(p1, p2, second), select(r1, r2, second), origin);
t.uv += b.y * t1.uv;
uv_x += bx.y * t1.uv;
uv_y += by.y * t1.uv;
t.color += b.y * t1.color;
t.spherical += b.y * t1.spherical;
t.cylindrical += b.y * t1.cylindrical;
t.visibility += b.y * t1.visibility;
let t2 = vis_corner(select(p2, p3, second), select(r2, r3, second), origin);
t.uv += b.z * t2.uv;
uv_x += bx.z * t2.uv;
uv_y += by.z * t2.uv;
t.color += b.z * t2.color;
t.spherical += b.z * t2.spherical;
t.cylindrical += b.z * t2.cylindrical;
t.visibility += b.z * t2.visibility;
let du = (uv_x - t.uv) * toward.x;
let dv = (uv_y - t.uv) * toward.y;
// HDR output alone reads the light levels and the face's normal (as in `fs_opaque`):
// `terrain_vertex` gives them as the block light over 16 and the sky light over 16, and the
// section-relative position.
if globals.hdr_light.z != 0.0 {
let local = array(bitcast<vec3<f32>>(p0), bitcast<vec3<f32>>(select(p1, p2, second)),
bitcast<vec3<f32>>(select(p2, p3, second)));
// `t.scene` is still the first corner's.
t.scene = b.x * t.scene + b.y * t1.scene + b.z * t2.scene;
t.local = b.x * local[0] + b.y * local[1] + b.z * local[2];
let dlx = (bx.x * local[0] + bx.y * local[1] + bx.z * local[2] - t.local) * toward.x;
let dly = (by.x * local[0] + by.y * local[1] + by.z * local[2] - t.local) * toward.y;
hdr_normal = normalize(cross(dly, dlx));
}
// `fs_opaque` and `solid_color` past their discards, which pass 1 already made.
let texel = vis_sprite(t.uv, t.layer & 0x7fffu, du, dv);
var color = texel * t.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, t.visibility);
return solid_finish_at(t, texel, color, du, dv);
}
@@ -386,6 +386,7 @@ impl World {
chunk.motion_blocking(x & 15, z & 15, |v| blocks(BlockStateId(v)))
})
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.chunks
.get(&(x >> 4, z >> 4))
@@ -0,0 +1,191 @@
//! Original synthetic rendering controls for ordinary uniform initial values.
use horizon_client_render::gfx::pack_uniforms::Value;
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
use wgpu::util::DeviceExt;
fn source(code: &str) -> Preprocessed {
Preprocessed {
code: code.into(),
files: vec!["original-render.glsl".into()],
lines: code
.lines()
.enumerate()
.map(|(i, _)| Origin {
file: 0,
line: i as u32 + 1,
})
.collect(),
version: Some((430, None)),
..Default::default()
}
}
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn authored_defaults_render_and_runtime_values_override_including_zero() {
let program = translate_composite(
&source("void main() { gl_Position = ftransform(); }"),
&source("uniform float gain = 0.375; uniform mat3 basis = mat3(2.0); uniform mat4 transform = mat4(1.0); uniform vec3 tint = vec3(0.125,0.25,0.5); uniform float points[2] = float[](0.0625,0.125); uniform int mode = 3; uniform bool enabled = true; out vec4 color; void main() { color = vec4(gain * basis[0][0] * transform[2][2], tint.g + points[1], float(mode), enabled ? 1.0 : 0.0); }"),
Target::Buffers,
).unwrap();
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
.expect("GPU adapter required; missing adapter is not a pass");
eprintln!("Uniform defaults GPU: {:?}", adapter.get_info());
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.vertex)),
});
let fragment = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment)),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Float,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let target = 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: wgpu::TextureFormat::Rgba32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
for (name, zero_buffer, overrides, expected) in [
("authored defaults", false, vec![], [0.75, 0.375, 3.0, 1.0]),
(
"runtime precedence",
false,
vec![
("gain", Value::F([0.125; 16])),
("transform", Value::F([0.5; 16])),
("mode", Value::I([7, 0, 0])),
("enabled", Value::I([0; 3])),
],
[0.125, 0.375, 7.0, 0.0],
),
(
"runtime explicit zero",
false,
vec![("gain", Value::F([0.; 16])), ("mode", Value::I([0; 3]))],
[0.0, 0.375, 0.0, 1.0],
),
("zero upload negative control", true, vec![], [0.; 4]),
] {
let mut bytes = if zero_buffer {
vec![0; program.interface.uniform_size as usize]
} else {
program.interface.initial_values.clone()
};
for (name, value) in overrides {
let uniform = program
.interface
.uniforms
.iter()
.find(|u| u.name == name)
.unwrap();
value.write(&uniform.ty, &mut bytes, uniform.offset as usize);
}
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(name),
contents: &bytes,
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: buffer.as_entire_binding(),
}],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..4, 0..1);
}
encoder.copy_texture_to_buffer(
target.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(256),
rows_per_image: Some(1),
},
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
{
let mapped = readback.slice(..).get_mapped_range().unwrap();
let actual: &[f32] = bytemuck::cast_slice(&mapped[..16]);
eprintln!("{name}: {actual:?}");
assert_eq!(actual, expected, "{name}");
}
readback.unmap();
}
}
@@ -0,0 +1,482 @@
{
"horizon.options.controller": "Beheerder",
"horizon.options.vibration": "Vibrasie",
"horizon.options.adaptive_triggers": "Aanpasbare snellers",
"horizon.options.lightbar": "Ligbalk",
"horizon.options.look_sensitivity": "Kyksensitiwiteit",
"horizon.options.invert_x": "Omgekeerde kyk X",
"horizon.options.invert_y": "Omgekeerde kyk Y",
"horizon.options.sneak_button": "Sluipknoppie",
"horizon.options.move_deadzone": "Bewegingsdooiesone",
"horizon.options.look_deadzone": "Kykdooiesone",
"horizon.options.button_glyphs": "Knoppie Glyphs",
"horizon.options.menu_navigation": "Kieslysnavigasie",
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
"horizon.options.chat_delay": "Chat Vertraging",
"horizon.options.lightbar.health": "Gesondheid",
"horizon.options.lightbar.dimension": "Dimensie",
"horizon.options.glyphs.auto": "Outo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wyser",
"horizon.options.keyboard.auto": "Outo",
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"horizon.options.search": "Soek instellings...",
"horizon.pages.shader_packs": "Shader Pakke",
"horizon.pages.shader_options": "Shader Opsies",
"horizon.pages.controller": "Beheerderinstellings",
"horizon.pages.controller_binds": "Kontroleerder bind",
"horizon.pages.extras": "Horizon-ekstras",
"horizon.pages.settings": "Horizon-instellings",
"horizon.pages.agent": "KI Agent",
"horizon.split.leave": "Los",
"horizon.pages.split_settings": "Verdeelskerm-instellings",
"horizon.pages.split_profiles": "Wie speel?",
"horizon.pages.split_setup": "Verdeelskerm-opstelling",
"horizon.pages.music": "Musiek",
"horizon.pages.music.menu": "Spyskaart",
"horizon.pages.music.overworld": "Oorwêreld",
"horizon.pages.music.creative": "Kreatief",
"horizon.pages.music.underwater": "Onderwater",
"horizon.pages.music.end": "Die Einde",
"horizon.pages.music.boss": "Baas",
"horizon.pages.music.credits": "Krediete",
"horizon.pages.music.overworld_biomes": "Oorwêreld biome",
"horizon.pages.music.nether_biomes": "Onder biome",
"horizon.pages.music.end_biomes": "Eindig biome",
"horizon.pages.music.other": "Ander musiek",
"horizon.pages.music.empty": "Geen musieksnitte nie: die speletjie se klanke is nie gelaai nie.",
"horizon.pages.music.tooltip": "Die ingeboude snitte, volgens situasie: speel nou een, of skakel enige af sodat die speletjie ophou om dit te kies.",
"horizon.pages.settings.tooltip": "Horizon se eie instellings: grafika, beheerders, ekstras en mods.",
"horizon.pages.graphics.tooltip": "Grafika, eenvoudig: elke instelling sê wat dit kos.",
"horizon.pages.graphics_advanced.tooltip": "Elke grafiese instelling, Horizon s'n ingesluit, met wat elkeen kos.",
"horizon.pages.controller.tooltip": "Beheerders: hul knoppies, stokke, vibrasie en spyskaartnavigasie.",
"horizon.pages.extras.tooltip": "Opsionele toevoegings, alles by verstek af: die speletjie bly vanielje totdat jy een aanskakel.",
"horizon.pages.agent.tooltip": "Laat jou eie KI-agent speel: horizon-ctl en MCP-kliënte op hierdie rekenaar.",
"horizon.pages.split_profiles.tooltip": "Wie speel: 'n profiel wat in hierdie speletjie-lêergids gehou word.",
"horizon.pages.split_setup.tooltip": "Die vensters en monitors, en wie speel waar.",
"horizon.pages.shader_packs.tooltip": "Shader-pakke vanaf die shaderpacks-lêergids: Horizon se eie en dié in die shaders/formaat. Een hardloop op 'n slag.",
"horizon.pages.mods.tooltip": "Mods gemaak vir Horizon, vanaf die horizon-mods-lêergids. Elkeen loop in 'n sandbox en bly af totdat jy dit aanskakel.",
"horizon.pages.open.graphics": "Grafika...",
"horizon.pages.open.graphics_advanced": "Gevorderde grafika...",
"horizon.pages.open.shader_packs": "Shader-pakke...",
"horizon.pages.open.controller": "Beheerderinstellings...",
"horizon.pages.open.split_setup": "Verdeelskerm...",
"horizon.pages.open.extras": "Ekstras...",
"horizon.pages.open.mods": "Modusse...",
"horizon.pages.open.agent": "KI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon-grafika...",
"horizon.pages.open.controller_binds": "Beheerder bind...",
"horizon.pages.open.horizon_settings": "Horizon-instellings...",
"horizon.pages.open.music": "Musiek...",
"horizon.mods.empty": "Geen mods: plaas .hmod-lêers in die horizon-mods-lêergids",
"horizon.mods.stopped": " (Gestop)",
"horizon.mods.not_built": " (Nie gebou nie)",
"horizon.mods.dev": " (Ontwikkelaar)",
"horizon.mods.settings": "Instellings",
"horizon.mods.in_world": " (los die wêreld om te verander)",
"horizon.mods.open_folder": "Maak Mod-lêergids oop",
"horizon.shader_packs.empty": "Geen shader packs: plaas dit in die shaderpacks-lêergids",
"horizon.shader_options.open": "Shader-opsies...",
"horizon.shader_options.profile": "Profiel",
"horizon.shader_options.loading": "Lees tans die shader-pakket se opsies...",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
"horizon.multiplayer.cancel_reconnecting": "Kanselleer Herkoppel",
"horizon.multiplayer.join_friend": "Sluit aan by 'n Vriend",
"horizon.multiplayer.invite_code": "Uitnodigingskode",
"horizon.multiplayer.relay": "Aflos (opsioneel)",
"Preset": "Vooraf ingesteld",
"LOD Distance": "LOD Afstand",
"Far Detail": "Verre Detail",
"Cave Culling": "Grotuitdunning",
"Entity Culling": "Entiteit Uitdunning",
"Upload Budget": "Laai begroting op",
"Sky": "Hemel",
"Fog": "Mis",
"Weather": "Weer",
"Animated Textures": "Geanimeerde teksture",
"Present Mode": "Aanbiedingsmodus",
"Frame Pacing": "Raamtempo",
"Worker Threads": "Werkerdrade",
"Resolution Scale": "Resolusieskaal",
"Upscaling": "Opskaling",
"Paper White": "Papier Wit",
"Peak Brightness": "Piek helderheid",
"Black Level": "Swart vlak",
"UI Brightness": "UI Helderheid",
"Battery Mode": "Batterymodus",
"Graphics API": "Grafiese API",
"Translucent Batching": "Deurskynende Batching",
"Performance Overlay": "Prestasie-oorleg",
"Full Detail Distance": "Volledige detail Afstand",
"On": "Aan",
"Off": "Af",
"Unlimited": "Onbeperk",
"Auto": "Outo",
"Custom": "Pasgemaak",
"Performance": "Prestasie",
"Balanced": "Gebalanseerd",
"Quality": "Kwaliteit",
"Ultra Performance": "Ultra prestasie",
"Native": "Inheems",
"Full": "Vol",
"Reduced": "Verminder",
"Efficient": "Doeltreffend",
"Precise": "Presies",
"Auto (VSync)": "Outo (VSync)",
"FIFO": "EIEU",
"Mailbox": "Posbus",
"Immediate": "Onmiddellik",
"FPS and Graph": "FPS en grafiek",
"No cost": "Geen koste nie",
"Low cost": "Lae koste",
"Medium cost": "Medium koste",
"High cost": "Hoë koste",
"Saves time": "Spaar tyd",
"Graphics": "Grafika",
"Done": "Klaar",
"This feature is not yet available in Horizon.": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
"Your display or system doesn't offer HDR output.": "Jou skerm of stelsel bied nie HDR-uitvoer nie.",
"Turn HDR on to use it.": "Skakel HDR aan om dit te gebruik.",
"Your GPU or graphics API doesn't support it.": "Jou GPU of grafiese API ondersteun dit nie.",
"%s chunks": "%s stukke",
"%s per frame": "%s per raam",
"%s MiB/frame": "%s MiB/raam",
"%s KiB/frame": "%s KiB/raam",
"Auto (%s)": "Outo (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Volledige detail terreinreeks. LOD Distance kan plaaslike wêrelde verder uitbrei.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Growwe verre terrein in plaaslike wêrelde. Geïntegreerde GPU's stop by 128 stukke.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Verminder trek veel terrein growwer: vinniger op hierdie geïntegreerde GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Verminder trek veel terrein growwer, vir GPU's word dit stadiger.",
"Field of view. Wider shows more terrain at once.": "Gesigsveld. Wyer wys meer terrein op een slag.",
"Caps frames per second; a cap saves power and heat.": "Caps rame per sekonde; 'n pet spaar krag en hitte.",
"Waits for the display's refresh: no tearing, a little latency.": "Wag vir die verversing van die skerm: geen skeur nie, 'n bietjie vertraging.",
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Slaan terrein oor wat agter geslote gedeeltes versteek is. Af kos baie.",
"Hides entities behind opaque blocks.": "Versteek entiteite agter ondeursigtige blokke.",
"No entity occlusion culling.": "Geen entiteit-okklusion uitskot nie.",
"CPU occlusion test runs this frame.": "SVE-okklusietoets loop hierdie raam.",
"Occlusion checks run on a worker thread.": "Okklusiekontroles loop op 'n werkersdraad.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Maasafdelings per raam opgelaai. Laer is gladder tydens laai.",
"Soft shadows in corners. Costs chunk building time only.": "Sagte skaduwees in hoeke. Dit kos slegs 'n stuk boutyd.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleiner kopieë van blokteksture vir verafgeleë terrein: minder glans.",
"Blends grass and water colours across biomes when building chunks.": "Vermeng gras en waterkleure oor biome wanneer stukke gebou word.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolklaag: af, plat (vinnig) of 3D (fancy).",
"How far clouds reach, in chunks.": "Hoe ver wolke reik, in stukke.",
"Draws the sky, sun, moon and stars.": "Teken die lug, son, maan en sterre.",
"Distance fog. Off shows the edge of the loaded world.": "Afstand mis. Af wys die rand van die gelaaide wêreld.",
"Draws rain and snow.": "Trek reën en sneeu.",
"How far rain and snow fall around you.": "Hoe ver val reën en sneeu om jou.",
"How many particles effects spawn.": "Hoeveel deeltjies effekte kuit.",
"Round shadows under mobs and items.": "Ronde skaduwees onder skares en items.",
"Leaves with see-through gaps. Off draws them solid.": "Blare met deursigtige gapings. Off trek hulle stewig.",
"Sorts water and glass more exactly behind each other.": "Sorteer water en glas meer presies agter mekaar.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Watter stuk herbou wag vir die raam: geen, naby of joune.",
"Animates water, lava, fire and other moving textures.": "Animeer water, lawa, vuur en ander bewegende teksture.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Wêreldpixels per skermpiksel. Onder 100% is vinniger op die GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Maak gekartelde rande glad. FXAA: sagter. TAA: gladste, bewegende dinge kan loop.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Maak die wêreld kleiner en herbou dit met TAA. Vinniger, sagter.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-uitset op 'n HDR-skerm: lawa, vuur en sonlig skyn helderder.",
"How frames reach the screen while VSync is off.": "Hoe rame die skerm bereik terwyl VSync af is.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Doeltreffende slaap tot die volgende raam; Presiese draaie, soos vanielje.",
"Fills the screen (borderless).": "Vul die skerm (randloos).",
"Brightens dark places.": "Verhelder donker plekke.",
"Lowers the frame rate when minimised, or idle too.": "Verlaag die raamtempo wanneer dit geminimaliseer word, of ook ledig.",
"Sways the view while walking.": "Swaai die uitsig terwyl jy stap.",
"Fades newly loaded chunks in.": "Vervaag nuutgelaaide stukke in.",
"Darkens the screen's edges.": "Maak die skerm se rande donkerder.",
"Shows when the world saves.": "Wys wanneer die wêreld red.",
"Shows attack cooldown.": "Toon aanvalafkoeling.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS pet (30 in spyskaarte of ongefokus), die helfte van die werkers: minder krag.",
"Threads that generate, light and mesh chunks. Next start.": "Drade wat stukke genereer, lig en maas. Volgende begin.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, dan DirectX 12 (Windows) of OpenGL (Linux) waar dit misluk. Volgende begin.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Water en glas in een buffer: minder trekoproepe, 'n bietjie meer VRAM. Volgende begin.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Maasdata per raam na die GPU gestuur. Laer is gladder tydens laai.",
"Frame rate and frame times over the game, with a graph.": "Raamtempo en raamtye oor die spel, met 'n grafiek.",
"horizon.multiplayer.finding_friend": "Soek jou vriend se wêreld...",
"horizon.multiplayer.not_signed_in": "Hierdie bediener is in aanlynmodus. Meld aan met jou Microsoft-rekening in die Horizon-lanseerder om daarby aan te sluit.",
"horizon.multiplayer.invalid_address": "Ongeldige bedieneradres: %s",
"horizon.multiplayer.connected_friend_direct": "Reguit gekoppel aan jou vriend...",
"horizon.multiplayer.connected_friend_relay": "Gekoppel aan jou vriend deur die aflos...",
"General": "Algemeen",
"Advanced": "Gevorderd",
"Undo": "Ontdoen",
"Search options": "Soek opsies",
"Shortcut": "Kortpad",
"Distance": "Afstand",
"Display": "Vertoon",
"Interface": "Koppelvlak",
"Sky and weather": "Lug en weer",
"Lighting and entities": "Beligting en entiteite",
"Textures": "Teksture",
"Image": "Beeld",
"Chunk loading": "Chunk laai",
"Culling": "Uitskot",
"Power": "Krag",
"Frames": "Rame",
"Overlay": "Oorleg",
"Output": "Uitset",
"Brightness": "Helderheid",
"How far you see, the window and the HUD.": "Hoe ver jy sien, die venster en die HUD.",
"The sky and weather, lighting, textures and the image.": "Die lug en weer, beligting, teksture en die beeld.",
"Chunk loading, culling and power use.": "Chunk laai, uitskot en kraggebruik.",
"How frames reach the screen, and the performance overlay.": "Hoe rame die skerm bereik, en die prestasie-oorleg.",
"Brighter-than-white output on an HDR display.": "Helderder-as-wit uitset op 'n HDR-skerm.",
"Results for \"%s\"": "Resultate vir \"%s\"",
"No settings match.": "Geen instellings pas nie.",
"horizon.title.not_affiliated": "Nie geaffilieer met Mojang of Microsoft nie",
"horizon.extras.header.lighting": "Beligting",
"horizon.extras.header.third_person": "Derde persoon",
"horizon.extras.header.particles": "Deeltjies",
"horizon.extras.header.inventory": "Voorraad",
"horizon.extras.header.chat": "Gesels",
"horizon.extras.header.multiplayer": "Multispeler",
"horizon.extras.header.sound": "Klank",
"horizon.extras.header.menus": "Spyskaarte",
"horizon.extras.fullbright": "Vol helder",
"horizon.extras.dynamic_lights": "Dinamiese ligte",
"horizon.extras.own_name_tag": "Eie naamplaatjie",
"horizon.extras.hide_block_particles": "Versteek blokbreekdeeltjies",
"horizon.extras.auto_reconnect": "Herkoppel outomaties",
"horizon.extras.ui_sound_reserve": "Hou kanale vir UI-klanke",
"horizon.extras.music_page": "Musiek bladsy",
"horizon.extras.mouse_tweaks": "Muis Tweaks kontroles",
"horizon.extras.mouse_tweaks_wheel": "Rolwiel beweeg items",
"horizon.extras.sort": "Sorteer knoppies",
"horizon.extras.sort_key": "Sorteer sleutel",
"horizon.extras.no_ai": "Geen KI",
"horizon.extras.agent_control": "Agent beheer",
"horizon.extras.discord_presence": "Ryk teenwoordigheid op Discord",
"horizon.extras.splitscreen": "Verdeelskerm",
"horizon.extras.chat_animation": "Glad gesels",
"horizon.extras.chat_indicator": "Boodskap aanwyser",
"horizon.extras.wheel_cycles_settings": "Rolwiel verander instellings",
"horizon.extras.pack_browser": "Hulpbronpakketblaaier",
"horizon.extras.fullbright.tooltip": "Wys blokke en entiteite in volle lig, selfs in donker areas.",
"horizon.extras.dynamic_lights.tooltip": "Fakkels en ander ligbronne wat vasgehou, laat val word of aan die brand steek, steek die blokke om hulle aan.",
"horizon.extras.own_name_tag.tooltip": "Wys jou naam bo jou kop in derde persoon, soos ander spelers dit sien. Versteek wanneer die bediener jou span se name versteek of jy onsigbaar is.",
"horizon.extras.hide_block_particles.tooltip": "Versteek die puindeeltjies wat uit 'n gebreekte blok bars en verkrummel van die een wat jy myn. Ander deeltjies bly.",
"horizon.extras.auto_reconnect.tooltip": "Sluit weer by 'n bediener aan na 'n verlore verbinding of 'n herbegin, nooit na 'n skop of verbod nie.",
"horizon.extras.ui_sound_reserve.tooltip": "Klanke in die wêreld laat 16 van die 247 klankkanale vry, so 'n vloed van hulle ('n groot ontploffing) kan nie kieslysklikke stilmaak nie.",
"horizon.extras.music_page.tooltip": "Musiek en Klanke kry 'n Musiek...-bladsy: speel nou enige ingeboude snit, of skakel een af sodat die speletjie ophou om dit te kies. Niks verander totdat jy 'n snit afskakel nie.",
"horizon.extras.mouse_tweaks.tooltip": "Regs-sleep plaas een per gleuf, links-sleep versamel die gehou item, Shift + links-sleep skuif elke gleuf.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Af stuur een item van die gleuf na die ander kant, op bring een terug.",
"horizon.extras.sort.tooltip": "Sorteer die voorraad, kiste, vate, eindkiste en shulkerbokse in kreatiewe oortjievolgorde, en voeg stapels saam.",
"horizon.extras.sort_key.tooltip": "Sorteer die deel onder die wyser. Klik en druk dan 'n sleutel; Ontvlugting ontbind dit.",
"horizon.extras.no_ai.tooltip": "Versteek en skakel elke KI-kenmerk af: agentbeheer, horizon-ctl en die MCP-bedieners. Die lanseerder se skakelaar ook.",
"horizon.extras.agent_control.tooltip": "Jou eie KI-agent kan hierdie speletjie deur horizon-ctl of 'n MCP-kliënt speel, slegs op hierdie rekenaar.",
"horizon.extras.discord_presence.tooltip": "Wys Playing Horizon op jou Discord-profiel terwyl Discord loop: spyskaarte, enkelspeler of multispeler, die dimensie en die speeltyd. Nooit 'n wêreld se naam of 'n bediener se adres nie.",
"Calibrate...": "Kalibreer...",
"Calibrate Anyway...": "Kalibreer in elk geval...",
"Use Automatic Setup": "Gebruik outomatiese opstelling",
"Shortcut: %s": "Kortpad: %s",
"Next": "Volgende",
"HDR Calibration %s/%s: %s": "HDR-kalibrasie %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Stel die drie hieronder volskerm, een toetsprent op 'n slag.",
"A key that opens this screen from the game. Not bound at first.": "'n Sleutel wat hierdie skerm vanaf die speletjie oopmaak. Eers nie gebonde nie.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Jou skerm het nie gesê hoe helder dit word nie: kalibreer om dit op te stel.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Reeds opgestel vir jou vertoning. Kalibreer slegs as HDR verkeerd lyk.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Plaas Peak Brightness en Paper White terug na jou skerm se opstelling.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Stel op vir jou vertoning. Los op Auto tensy dit verkeerd lyk.",
"Lower it until the logo just disappears into the black.": "Laat sak dit totdat die logo net in die swart verdwyn.",
"Raise it until the logo just disappears into the bright square.": "Lig dit op totdat die logo net in die helder vierkant verdwyn.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Maak die kaart 'n wit wat gemaklik is om lank na te kyk, in die lig waarin jy speel.",
"horizon.splitscreen.profiles.forget": "Vergeet",
"horizon.splitscreen.profiles.create": "Skep",
"horizon.splitscreen.profiles.you": " - jy",
"horizon.splitscreen.profiles.forget_named": "Vergeet %s",
"horizon.splitscreen.profiles.previous": "Vorige",
"horizon.splitscreen.profiles.next": "Volgende",
"horizon.splitscreen.profiles.new_button": "Nuwe profiel...",
"horizon.splitscreen.profiles.forget_title": "Vergeet profiel?",
"horizon.splitscreen.profiles.forget_leaves": "Dit verlaat hierdie instansie se lys.",
"horizon.splitscreen.profiles.forget_kept": "Wêreldvordering en instellings word behou.",
"horizon.splitscreen.profiles.new_title": "Nuwe profiel",
"horizon.splitscreen.profiles.name_hint": "Naam (3 tot 16 letters, syfers, _)",
"horizon.splitscreen.profiles.switch_title": "Wissel profiel",
"horizon.splitscreen.profiles.offline_note": "Vanlyn profiele behoort aan hierdie geval",
"horizon.splitscreen.profiles.move": "Beweeg waar jy speel",
"horizon.splitscreen.profiles.move_place": "Beweeg waar jy speel: %s",
"horizon.friends.title": "Speel met vriende",
"horizon.friends.hint": "Vriende plak die kode na die titelskerm se Vriende-knoppie, in Direkte verbinding, of op die lanseerder se Speel-bladsy.",
"horizon.friends.copied": "Gekopieer",
"horizon.friends.copy_code": "Kopieer kode",
"horizon.friends.stop_sharing": "Hou op deel",
"horizon.friends.none_yet": "Nog geen vriende hier nie.",
"horizon.friends.here_now": "Hier nou: %s",
"horizon.friends.share_intro": "Deel hierdie wêreld agter 'n uitnodigingskode: dit maak oop na LAN, en vriende op die internet sluit aan met die kode. Geen bediener van ons is betrokke nie.",
"horizon.friends.start_sharing": "Begin deel",
"horizon.friends.troubleshooting": "Foutsporing …",
"horizon.friends.stopped": "Het opgehou om die uitnodigingkode te deel.",
"horizon.friends.short_code_changed": "Iemand het aanhou om 'n verkeerde kort kode te probeer, so dit het verander.",
"horizon.friends.wrong_code": "Iemand het 'n verkeerde kode probeer.",
"horizon.friends.code_ready": "Jou uitnodigingskode is gereed: Speletjie-kieslys, Speel met vriende. Kopieer dit en stuur dit aan jou vriende.",
"horizon.friends.share_failed": "Kon nie die wêreld deel nie: %s",
"horizon.friends.finding_route": "Vind tans hoe vriende jou kan bereik …",
"horizon.osk.shift": "Verskuiwing",
"horizon.osk.space": "Ruimte",
"horizon.osk.delete": "Vee uit",
"horizon.osk.type": "Tik",
"horizon.osk.symbols": "Simbole",
"horizon.osk.close": "Maak toe",
"horizon.extras.chat_animation.tooltip": "'n Nuwe kletslyn gly van links af terwyl dit vervaag, in plaas daarvan om heel te lyk.",
"horizon.extras.chat_indicator.tooltip": "Die gekleurde balk aan die linkerkant van 'n kletslyn.",
"horizon.extras.wheel_cycles_settings.tooltip": "Die rolwiel oor 'n knoppie in die instellings verander die waarde daarvan, soos in vanielje. Af, die wiel blaai net deur die lys.",
"horizon.extras.pack_browser.tooltip": "Select Resource Packs kry 'n Voeg by-knoppie wat Modrinth deursoek en 'n pakket in jou pakke-lêergids installeer. Af, die speletjie kontak nooit Modrinth nie.",
"horizon.extras.splitscreen.tooltip": "In 'n wêreld kan 'n ander beheerder as 'n nuwe speler aansluit: druk A wanneer dit vra, kies dan 'n profiel en waar om te speel.",
"horizon.splitscreen.setup.monitor_unknown": "'n monitor",
"horizon.splitscreen.setup.window": "Venster %s",
"horizon.splitscreen.setup.new_window": "Nuwe venster",
"horizon.splitscreen.setup.fill_monitor": "Vul %s",
"horizon.splitscreen.setup.done": "Klaar",
"horizon.splitscreen.setup.on": "Aan",
"horizon.splitscreen.setup.off": "Af",
"horizon.splitscreen.setup.player": "Speler %s",
"horizon.splitscreen.setup.revert": "Keer terug",
"horizon.splitscreen.setup.apply": "Doen aansoek",
"horizon.splitscreen.setup.warning.empty_main": "Niemand speel in Venster 1 nie: dit skuil",
"horizon.splitscreen.setup.warning.empty_close": "Niemand speel in Venster %s nie: dit sluit op Toepassing",
"horizon.splitscreen.setup.warning.empty_new": "Niemand speel in Venster %s nie: dit sal nie oopmaak nie",
"horizon.splitscreen.setup.warning.on_main": "Venster %s vul Venster 1 se monitor: sy spelers speel in Venster 1",
"horizon.splitscreen.setup.warning.unplugged": "Venster %s se monitor is nie gekoppel nie: sy spelers speel in Venster 1",
"horizon.splitscreen.setup.status.off": "Verdeelskerm is af.",
"horizon.splitscreen.setup.status.enable_hint": "Skakel dit aan en druk dan 'n knoppie op 'n ander beheerder in 'n wêreld.",
"horizon.splitscreen.setup.status.join_hint": "Druk A om aan te sluit en kies 'n profiel.",
"horizon.splitscreen.setup.status.idle": "Gedeelskerm ledig",
"horizon.splitscreen.setup.status.alone": "Geen ander plaaslike spelers nie.",
"horizon.splitscreen.setup.status.solo_controls": "Solo-kontroles en kontroleerder-terugvoer is onveranderd.",
"horizon.splitscreen.setup.window_empty": "Venster %s: leeg",
"horizon.splitscreen.setup.note.select": "Klik 'n speler, of sleep dit na 'n venster of 'n monitor.",
"horizon.splitscreen.setup.note.apply": "Doen aansoek om op hierdie manier te speel, Keer terug om te ontdoen.",
"horizon.splitscreen.setup.note.place": "Waar moet %s speel?",
"horizon.splitscreen.setup.this_player": "hierdie speler",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen skakel af sodra die ander vertrek het (hul pousekieslys).",
"horizon.splitscreen.setup.window_place": "Venster %s (%s van %s)",
"horizon.splitscreen.setup.names": "Name",
"horizon.splitscreen.setup.identify": "Identifiseer",
"horizon.splitscreen.setup.layout.two": "2 deel: %s",
"horizon.splitscreen.setup.layout.three": "3 deel: %s",
"horizon.splitscreen.setup.layout.two.0": "Outo",
"horizon.splitscreen.setup.layout.two.1": "Sy aan sy",
"horizon.splitscreen.setup.layout.two.2": "Bo en onder",
"horizon.splitscreen.setup.layout.three.0": "Een breed bo-op",
"horizon.splitscreen.setup.layout.three.1": "Een lang aan die linkerkant",
"horizon.splitscreen.setup.layout.three.2": "Sy aan sy",
"horizon.splitscreen.setup.help.player": "Klik of sleep %s om hulle te skuif.",
"horizon.splitscreen.setup.help.layout_auto": "%s (naas mekaar wanneer die venster wyd is). Druk om te verander.",
"horizon.splitscreen.setup.help.layout": "%s. Druk om te verander.",
"horizon.splitscreen.setup.help.switch": "Verdeelskerm vir hierdie rekenaar, aan of af.",
"horizon.splitscreen.setup.help.identify": "Elke speler se aansig wys in watter venster dit is.",
"horizon.splitscreen.setup.help.names": "Elke speler se naam in die hoek van hul aansig.",
"horizon.splitscreen.setup.help.revert": "Ontdoen die veranderinge wat nog nie toegepas is nie.",
"horizon.splitscreen.setup.help.apply": "Speel soos die kaart wys.",
"horizon.splitscreen.setup.help.unchanged": "Niks het nog verander nie.",
"horizon.splitscreen.setup.give_up_pad": "Gee op Pad",
"horizon.splitscreen.prompt.controller_connected": "Beheerder gekoppel",
"horizon.splitscreen.prompt.controller_disconnected": "Beheerder ontkoppel",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Koppel dit weer, of druk 'n knoppie op 'n ander",
"horizon.splitscreen.prompt.join.one": "%s wil aansluit",
"horizon.splitscreen.prompt.join.many": "%s beheerders wil aansluit",
"horizon.splitscreen.prompt.join.yes": "Sluit aan",
"horizon.splitscreen.prompt.join.as_p1": "Speel as P1",
"horizon.splitscreen.prompt.join.no": "Nee",
"horizon.splitscreen.prompt.lost": "%s: beheerder ontkoppel",
"horizon.splitscreen.prompt.lost.use_another": "Gebruik 'n ander",
"horizon.splitscreen.prompt.lost.leave": "Los",
"horizon.splitscreen.setup.help.give_up_pad": "Jou beheerder hou op om jou te bestuur en word aangebied om aan te sluit (A).",
"horizon.splitscreen.setup.note.held_at": "%s tot %s: A laat val dit hier, B sit dit terug.",
"horizon.splitscreen.setup.note.held": "%s opgetel: links/regs kies waar, A laat val, B sit dit terug.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteek modelle agter ondeursigtige blokke. Async kontroleer af-draad; entiteite merk.",
"Block Entities": "Blok Entiteite",
"Reuses settled boxes; lid movement stays animated.": "Hergebruik afgewikkelde bokse; dekselbeweging bly geanimeerd.",
"Signs": "Tekens",
"Reuses sign text until text, light, or font changes.": "Hergebruik tekenteks totdat teks, lig of lettertipe verander.",
"Decorated Pots": "Versierde potte",
"Reuses still pots; break wobble and sherd changes redraw.": "Hergebruik stilpotte; breek wankel en skeur veranderinge herteken.",
"Bells": "Klokke",
"Reuses still bells; ringing stays animated.": "Hergebruik stilstaande klokke; lui bly geanimeerd.",
"Chests": "Kiste",
"Reuses closed chests; moving lids stay animated.": "Hergebruik toe kiste; bewegende deksels bly geanimeerd.",
"Banners": "Baniere",
"Reuses poles; flags and pattern layers still update.": "Hergebruik pale; vlae en patroonlae werk steeds by.",
"Copper Golem Statues": "Koper Golem-standbeelde",
"Reuses statue geometry until its block state changes.": "Hergebruik standbeeldgeometrie totdat die bloktoestand daarvan verander.",
"Leaf Culling": "Blaaruitsny",
"Every leaf face. Vanilla's fancy leaves.": "Elke blaar gesig. Vanielje se spoggerige blare.",
"Distant Plant Dither": "Verre Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Hou fyn plante in hoë-resolusie-pakke om ver weg te vervaag.",
"Screenshot Quality": "Skermkiekie Kwaliteit",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 skermkiekies teen hierdie baie keer die spel se resolusie. Hoër neem langer.",
"Frame Generation": "Raam Generasie",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Toon 'n opgemaakte raam tussen elke twee regte: gladder, 'n bietjie meer vertraging.",
"Shadow Cache": "Skadu-kas",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Hergebruik die pakskadukaart wanneer son, uitsig en wêreld ooreenstem. Af by verstek.",
"Find BlueMap": "Vind BlueMap",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Op bedieners, vind hul BlueMap-webkaart vir ver terrein. Sluit volgende aan.",
"BlueMap Radius": "Blou Kaart Radius",
"How far around you BlueMap tiles are downloaded. Next join.": "Hoe ver om jou is BlueMap-teëls afgelaai. Sluit volgende aan.",
"Synchronous": "Sinchronies",
"Asynchronous": "Asinchronies",
"Solid": "Solied",
"Solid Aggressive": "Stewige aggressiewe",
"Hollow": "Hol",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blare volledig binne-in 'n blaredak trek solied: minder deurskynende lae.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Blare met een oop kant trek ook solied: vinniger, bonkiger rande.",
"Drops faces between leaves: faster, but gaps look through.": "Laat val gesigte tussen blare: vinniger, maar gapings kyk deur.",
"Hotbar: Previous Slot": "Hotbar: Vorige Slot",
"Hotbar: Next Slot": "Hotbar: Volgende Slot",
"horizon.agent.connected.one": "Gekoppel: 1 agentverbinding",
"horizon.agent.connected.many": "Gekoppel: %s agentverbindings",
"horizon.agent.connected.one_ago": "Gekoppel: 1 agentverbinding, laaste opdrag %s s gelede",
"horizon.agent.connected.many_ago": "Gekoppel: %s agentverbindings, laaste opdrag %s s gelede",
"horizon.agent.waiting": "Wag vir 'n agent.",
"horizon.agent.off": "Af: agente kan nie hierdie speletjie bereik nie.",
"horizon.agent.session_opened": "Geopen vir hierdie sessie deur die program wat die speletjie begin het.",
"horizon.agent.socket": "Sok: %s (kopieer die besonderhede vir sy volledige pad)",
"horizon.agent.local_only": "Slegs plaaslik: agente op hierdie rekenaar, nooit die netwerk nie.",
"%s nits": "%s nete",
"Auto (%s nits)": "Outo (%s nete)",
"%.2f nits": "%.2f nete",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteek modelle agter ondeursigtige blokke. Entiteite-opdatering. Af met pasgemaakte pakke.",
"horizon.extras.repro_keys": "Opname kortpaaie",
"horizon.extras.repro_keys.tooltip": "F3+R begin of stop 'n repro-opname en F3+E skryf sy bundel. Af by verstek: 'n opname kopieer die wêreld.",
"horizon.agent.record_session": "Begin opnamesessie",
"horizon.agent.record_stop": "Stop opnamesessie",
"horizon.agent.export_repro": "Voer Repro-bundel uit",
"horizon.repro.started": "Opname het begin; F3+R stop dit en skryf die bundel.",
"horizon.repro.stopped": "Opname gestop: %s",
"horizon.repro.bundle": "Repro-bundel: %s",
"horizon.repro.no_recording": "Daar is nog geen opname om uit te voer nie.",
"horizon.repro.replay_started": "Herhaling het begin: %s",
"horizon.pack.addInfo": "Voeg 'n hulpbronpakket van Modrinth by",
"horizon.pack.addWorking": "Vra vir Modrinth...",
"horizon.pack.addNone": "Geen hulpbronpakke gevind nie",
"horizon.pack.addTitle": "Voeg 'n hulpbronpakket by",
"horizon.pack.install": "Installeer",
"horizon.pack.installing": "Installeer tans",
"horizon.pack.installed": "Geïnstalleer",
"horizon.pack.search": "Soek",
"horizon.pack.searchHint": "Soek Modrinth...",
"horizon.pack.filter.realistic": "Realisties",
"horizon.pack.filter.simplistic": "Simplistiese",
"horizon.pack.filter.vanillaLike": "Vanielje-agtig",
"horizon.pack.addFailed": "Die pakkie kon nie bygevoeg word nie: %s",
"horizon.extras.header.cosmetics": "Skoonheidsmiddels",
"horizon.extras.cosmetics_mine": "My skoonheidsmiddels",
"horizon.extras.cosmetics_mine.tooltip": "Versteek: elke Horizon-speler sien jou sonder jou skoonheidsmiddels, jy ingesluit. Dieselfde skakelaar as die lanseerder se klerekas.",
"horizon.extras.cosmetics_others": "Ander spelers se skoonheidsmiddels",
"horizon.extras.cosmetics_others.tooltip": "Wat ander Horizon-spelers dra: alles, net hul naamplaatjiestyle, of niks. Versteek, die speletjie vra nooit wat hulle dra nie.",
"horizon.extras.cosmetics_auras": "Ander se aura's en roetes",
"horizon.extras.cosmetics_auras.tooltip": "Auras en roetes wat ander spelers dra. Af by verstek.",
"horizon.extras.cosmetics.shown": "Getoon",
"horizon.extras.cosmetics.hidden": "Versteek",
"horizon.extras.cosmetics.name_tags": "Slegs naamplaatjies"
}
@@ -0,0 +1,501 @@
{
"horizon.options.controller": "المراقب المالي",
"horizon.options.vibration": "الاهتزاز",
"horizon.options.adaptive_triggers": "المشغلات التكيفية",
"horizon.options.lightbar": "الشريط الضوئي",
"horizon.options.look_sensitivity": "حساسية النظر",
"horizon.options.invert_x": "عكس محور النظر X",
"horizon.options.invert_y": "عكس محور النظر Y",
"horizon.options.sneak_button": "زر التسلل",
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
"horizon.options.menu_navigation": "التنقل في القائمة",
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
"horizon.options.chat_delay": "تأخير الدردشة",
"horizon.options.lightbar.health": "الصحة",
"horizon.options.lightbar.dimension": "البعد",
"horizon.options.glyphs.auto": "تلقائي",
"horizon.options.glyphs.xbox": "اكس بوكس",
"horizon.options.glyphs.playstation": "بلاي ستيشن",
"horizon.options.glyphs.nintendo": "نينتندو",
"horizon.options.menu_focus": "التركيز",
"horizon.options.menu_pointer": "المؤشر",
"horizon.options.keyboard.auto": "تلقائي",
"horizon.options.keyboard.steam": "البخار",
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
"horizon.options.search": "إعدادات البحث...",
"horizon.pages.mods": "تعديل",
"horizon.pages.shader_packs": "حزم شادر",
"horizon.pages.shader_options": "خيارات التظليل",
"horizon.pages.controller": "إعدادات وحدة التحكم",
"horizon.pages.controller_binds": "ربط وحدة التحكم",
"horizon.pages.extras": "إضافات Horizon",
"horizon.pages.settings": "إعدادات Horizon",
"horizon.pages.agent": "وكيل منظمة العفو الدولية",
"horizon.split.leave": "غادر",
"horizon.pages.split_settings": "إعدادات تقسيم الشاشة",
"horizon.pages.split_profiles": "من يلعب؟",
"horizon.pages.split_setup": "إعداد تقسيم الشاشة",
"horizon.pages.music": "موسيقى",
"horizon.pages.music.menu": "القائمة",
"horizon.pages.music.overworld": "العالم الخارجي",
"horizon.pages.music.creative": "إبداعي",
"horizon.pages.music.underwater": "تحت الماء",
"horizon.pages.music.end": "النهاية",
"horizon.pages.music.boss": "رئيس",
"horizon.pages.music.credits": "الاعتمادات",
"horizon.pages.music.overworld_biomes": "المناطق الأحيائية في العالم الخارجي",
"horizon.pages.music.nether_biomes": "المناطق الأحيائية السفلى",
"horizon.pages.music.end_biomes": "نهاية المناطق الأحيائية",
"horizon.pages.music.other": "موسيقى أخرى",
"horizon.pages.music.empty": "لا توجد مقطوعات موسيقية: لا يتم تحميل أصوات اللعبة.",
"horizon.pages.music.tooltip": "المسارات المضمنة، حسب الموقف: العب واحدة الآن، أو قم بإيقاف تشغيل أي منها حتى تتوقف اللعبة عن اختيارها.",
"horizon.pages.settings.tooltip": "إعدادات Horizon الخاصة: الرسومات ووحدات التحكم والإضافات والتعديلات.",
"horizon.pages.graphics.tooltip": "الرسومات ببساطة: كل إعداد يوضح تكلفته.",
"horizon.pages.graphics_advanced.tooltip": "كل إعدادات الرسومات، بما في ذلك Horizon، وتكلفة كل منها.",
"horizon.pages.controller.tooltip": "وحدات التحكم: الأزرار والعصي والاهتزاز والتنقل في القائمة.",
"horizon.pages.extras.tooltip": "الإضافات الاختيارية، كلها متوقفة بشكل افتراضي: تظل اللعبة عادية حتى تقوم بتشغيلها.",
"horizon.pages.agent.tooltip": "اسمح لوكيل الذكاء الاصطناعي الخاص بك باللعب: عملاء Horizon-ctl وMCP على هذا الكمبيوتر.",
"horizon.pages.split_profiles.tooltip": "من يلعب: ملف تعريف محفوظ في مجلد اللعبة هذا.",
"horizon.pages.split_setup.tooltip": "النوافذ والشاشات ومن يلعب أين.",
"horizon.pages.shader_packs.tooltip": "حزم Shader من مجلد Shaderpacks: الخاصة بشركة Horizon وتلك الموجودة في تنسيق التظليل/. واحد يعمل في وقت واحد.",
"horizon.pages.mods.tooltip": "التعديلات المعدة لـ Horizon، من مجلد Horizon-mods. يعمل كل منها في وضع الحماية ويظل مغلقًا حتى تقوم بتشغيله.",
"horizon.pages.open.graphics": "الرسومات...",
"horizon.pages.open.graphics_advanced": "رسومات متقدمة...",
"horizon.pages.open.shader_packs": "حزم التظليل...",
"horizon.pages.open.controller": "إعدادات وحدة التحكم...",
"horizon.pages.open.split_setup": "شاشة مقسمة...",
"horizon.pages.open.extras": "إضافات...",
"horizon.pages.open.mods": "تعديل...",
"horizon.pages.open.agent": "وكيل الذكاء الاصطناعي...",
"horizon.pages.open.horizon_graphics": "رسومات Horizon...",
"horizon.pages.open.controller_binds": "ربط وحدة التحكم...",
"horizon.pages.open.horizon_settings": "إعدادات Horizon...",
"horizon.pages.open.music": "موسيقى...",
"horizon.mods.empty": "لا توجد تعديلات: ضع ملفات .hmod في مجلد Horizon-mods",
"horizon.mods.stopped": " (متوقف)",
"horizon.mods.not_built": " (لم يتم بناؤه)",
"horizon.mods.dev": " (ديف)",
"horizon.mods.settings": "الإعدادات",
"horizon.mods.in_world": " (اترك العالم ليتغير)",
"horizon.mods.open_folder": "افتح مجلد التعديل",
"horizon.shader_packs.empty": "لا توجد حزم تظليل: ضعها في مجلدshaderpacks",
"horizon.shader_options.open": "خيارات التظليل...",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.shader_options.loading": "قراءة خيارات حزمة التظليل...",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
"horizon.multiplayer.cancel_reconnecting": "إلغاء إعادة الاتصال",
"horizon.multiplayer.join_friend": "انضم إلى صديق",
"horizon.multiplayer.invite_code": "رمز الدعوة",
"horizon.multiplayer.relay": "التتابع (اختياري)",
"Preset": "محددة مسبقا",
"LOD Distance": "مسافة اللد",
"Far Detail": "تفاصيل بعيدة",
"Cave Culling": "إعدام الكهف",
"Entity Culling": "إعدام الكيان",
"Chunk Updates": "تحديثات القطعة",
"Upload Budget": "تحميل الميزانية",
"Sky": "السماء",
"Fog": "ضباب",
"Weather": "الطقس",
"Animated Textures": "القوام المتحركة",
"Present Mode": "الوضع الحالي",
"Frame Pacing": "سرعة الإطار",
"Worker Threads": "خيوط العمال",
"Resolution Scale": "مقياس القرار",
"Anti-aliasing": "مكافحة التعرج",
"Upscaling": "الارتقاء",
"Paper White": "ورقة بيضاء",
"Peak Brightness": "ذروة السطوع",
"Black Level": "المستوى الأسود",
"UI Brightness": "سطوع واجهة المستخدم",
"Battery Mode": "وضع البطارية",
"Graphics API": "واجهة برمجة تطبيقات الرسومات",
"Translucent Batching": "الخلط الشفاف",
"Performance Overlay": "تراكب الأداء",
"Full Detail Distance": "مسافة التفاصيل الكاملة",
"On": "على",
"Off": "إيقاف",
"Unlimited": "غير محدود",
"Auto": "تلقائي",
"Custom": "مخصص",
"Performance": "الأداء",
"Balanced": "متوازن",
"Quality": "الجودة",
"Ultra": "الترا",
"Ultra Performance": "أداء فائق",
"Native": "أصلي",
"Full": "كامل",
"Reduced": "مخفض",
"Efficient": "كفاءة",
"Precise": "دقيق",
"Auto (VSync)": "تلقائي (VSync)",
"FIFO": "يصرف أولاً",
"Mailbox": "صندوق البريد",
"Immediate": "فوري",
"Vulkan": "فولكان",
"DirectX 12": "دايركت اكس 12",
"OpenGL": "برنامج OpenGL",
"FPS and Graph": "FPS والرسم البياني",
"FPS": "إطارا في الثانية",
"TAA": "تأ",
"FXAA": "فكسا",
"No cost": "لا تكلفة",
"Low cost": "تكلفة منخفضة",
"Medium cost": "تكلفة متوسطة",
"High cost": "تكلفة عالية",
"Saves time": "يوفر الوقت",
"HDR": "تقرير التنمية البشرية",
"Graphics": "الرسومات",
"Done": "تم",
"This feature is not yet available in Horizon.": "هذه الميزة غير متوفرة بعد في Horizon.",
"Your display or system doesn't offer HDR output.": "لا توفر شاشتك أو نظامك مخرجات HDR.",
"Turn HDR on to use it.": "قم بتشغيل HDR لاستخدامه.",
"Your GPU or graphics API doesn't support it.": "وحدة معالجة الرسومات (GPU) أو واجهة برمجة تطبيقات الرسومات الخاصة بك لا تدعمها.",
"%s chunks": "%s قطع",
"%s per frame": "%s لكل إطار",
"%s MiB/frame": "%s MiB/الإطار",
"%s KiB/frame": "%s كيلو بايت/الإطار",
"Auto (%s)": "تلقائي (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "نطاق التضاريس كامل التفاصيل. يمكن لمسافة LOD أن تمتد إلى عوالم محلية أبعد.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "التضاريس البعيدة الخشنة في العوالم المحلية. تتوقف وحدات معالجة الرسومات المدمجة عند 128 قطعة.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "تم تقليل الرسم على التضاريس البعيدة: بشكل أسرع على وحدة معالجة الرسومات المدمجة هذه.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "يتم تقليل الرسوم على التضاريس البعيدة بشكل أكثر خشونة، أما بالنسبة لوحدات معالجة الرسومات فإنها تبطئ.",
"Field of view. Wider shows more terrain at once.": "مجال الرؤية. يُظهر العرض الأوسع المزيد من التضاريس في وقت واحد.",
"Caps frames per second; a cap saves power and heat.": "قبعات الإطارات في الثانية الواحدة؛ غطاء يوفر الطاقة والحرارة.",
"Waits for the display's refresh: no tearing, a little latency.": "في انتظار تحديث الشاشة: بدون تمزق، وقليل من زمن الوصول.",
"Size of menus and the HUD.": "حجم القوائم وHUD.",
"Sets the options below together. Changing one makes it Custom.": "يضبط الخيارات أدناه معًا. تغيير واحد يجعله مخصصًا.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "الحمم البركانية والنار وأشعة الشمس تتألق أكثر من اللون الأبيض. يحتاج إلى شاشة HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "اللون الأسود الداكن لشاشتك. 0 لـ OLED؛ تتوهج شاشات LCD قليلاً.",
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
"How bright the HUD and menus are, against paper white.": "مدى سطوع شاشة HUD والقوائم مقابل الورق الأبيض.",
"How far mobs, crops and machines keep working. CPU time.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "يتخطى التضاريس المخفية خلف الأقسام المغلقة. قبالة تكاليف الكثير.",
"Hides entities behind opaque blocks.": "يخفي الكيانات خلف الكتل غير الشفافة.",
"No entity occlusion culling.": "لا يوجد إعدام انسداد الكيان.",
"CPU occlusion test runs this frame.": "يقوم اختبار انسداد وحدة المعالجة المركزية بتشغيل هذا الإطار.",
"Occlusion checks run on a worker thread.": "يتم تشغيل عمليات فحص الانسداد على مؤشر ترابط عامل.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "تم تحميل المقاطع المتشابكة لكل إطار. الجزء السفلي أكثر سلاسة أثناء التحميل.",
"Soft shadows in corners. Costs chunk building time only.": "ظلال ناعمة في الزوايا. تكاليف بناء قطعة قطعة فقط.",
"Smaller copies of block textures for distant terrain: less shimmer.": "نسخ أصغر من أنسجة الكتل للتضاريس البعيدة: أقل وميضًا.",
"Blends grass and water colours across biomes when building chunks.": "يمزج ألوان العشب والماء عبر المناطق الأحيائية عند بناء القطع.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "طبقة السحابة: معطلة، مسطحة (سريعة) أو ثلاثية الأبعاد (فاخرة).",
"How far clouds reach, in chunks.": "إلى أي مدى تصل السحب، في قطع.",
"Draws the sky, sun, moon and stars.": "يرسم السماء والشمس والقمر والنجوم.",
"Distance fog. Off shows the edge of the loaded world.": "ضباب المسافة. يظهر Off حافة العالم المحمل.",
"Draws rain and snow.": "يرسم المطر والثلج.",
"How far rain and snow fall around you.": "إلى أي مدى تسقط الأمطار والثلوج من حولك.",
"How many particles effects spawn.": "كم عدد الجسيمات التي تفرخ الآثار.",
"Round shadows under mobs and items.": "ظلال مستديرة تحت الغوغاء والعناصر.",
"Leaves with see-through gaps. Off draws them solid.": "الأوراق بها فجوات شفافة. قبالة يرسمهم الصلبة.",
"Sorts water and glass more exactly behind each other.": "يقوم بفرز الماء والزجاج بشكل أكثر دقة خلف بعضهما البعض.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "أي قطعة سيتم إعادة بنائها تنتظر الإطار: لا شيء، قريب أو خاص بك.",
"Animates water, lava, fire and other moving textures.": "ينعش الماء والحمم والنار وغيرها من القوام المتحرك.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "بكسل العالم لكل بكسل الشاشة. أقل من 100% أسرع على وحدة معالجة الرسومات.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "ينعم الحواف الخشنة. FXAA: أكثر ليونة. TAA: قد تتبع الأشياء الأكثر سلاسة وتحريكًا.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "يجعل العالم أصغر حجمًا ويعيد بنائه باستخدام TAA. أسرع وأكثر ليونة.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "إخراج HDR على شاشة HDR: تتألق الحمم البركانية والنار وأشعة الشمس بشكل أكثر سطوعًا.",
"How frames reach the screen while VSync is off.": "كيفية وصول الإطارات إلى الشاشة أثناء إيقاف تشغيل VSync.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "ينام بكفاءة حتى الإطار التالي؛ يدور بدقة، مثل الفانيليا.",
"Fills the screen (borderless).": "يملأ الشاشة (بلا حدود).",
"Brightens dark places.": "يضيء الأماكن الداكنة.",
"Lowers the frame rate when minimised, or idle too.": "يخفض معدل الإطارات عند تصغيره أو في وضع الخمول أيضًا.",
"Sways the view while walking.": "يتأرجح المنظر أثناء المشي.",
"Fades newly loaded chunks in.": "يتلاشى القطع المحملة حديثًا.",
"Darkens the screen's edges.": "لتغميق حواف الشاشة.",
"Shows when the world saves.": "يظهر عندما ينقذ العالم.",
"Shows attack cooldown.": "يظهر تباطؤ الهجوم.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 إطارًا في الثانية (30 في القوائم أو غير مركزة)، نصف العمال: طاقة أقل.",
"Threads that generate, light and mesh chunks. Next start.": "الخيوط التي تولد القطع الخفيفة والشبكية. البداية التالية.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan، ثم DirectX 12 (Windows) أو OpenGL (Linux) حيث يفشل. البداية التالية.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "الماء والزجاج في مخزن مؤقت واحد: عدد أقل من مكالمات السحب، والمزيد من VRAM. البداية التالية.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "يتم إرسال بيانات الشبكة إلى وحدة معالجة الرسومات لكل إطار. الجزء السفلي أكثر سلاسة أثناء التحميل.",
"Frame rate and frame times over the game, with a graph.": "معدل الإطارات وأوقات الإطار خلال اللعبة، مع رسم بياني.",
"horizon.multiplayer.finding_friend": "البحث عن عالم صديقك...",
"horizon.multiplayer.not_signed_in": "هذا الخادم في وضع الاتصال بالإنترنت. قم بتسجيل الدخول باستخدام حساب Microsoft الخاص بك في Horizon Launcher للانضمام إليه.",
"horizon.multiplayer.invalid_address": "عنوان الخادم غير صالح: %s",
"horizon.multiplayer.connected_friend_direct": "تواصل مباشرة مع صديقك..",
"horizon.multiplayer.connected_friend_relay": "متصل بصديقك من خلال التتابع...",
"General": "عام",
"Advanced": "متقدم",
"Undo": "تراجع",
"Search options": "خيارات البحث",
"Shortcut": "الاختصار",
"Distance": "المسافة",
"Display": "عرض",
"Interface": "واجهة",
"Sky and weather": "السماء والطقس",
"Lighting and entities": "الإضاءة والكيانات",
"Textures": "القوام",
"Image": "صورة",
"Chunk loading": "تحميل قطعة",
"Culling": "إعدام",
"Power": "القوة",
"Frames": "إطارات",
"Overlay": "تراكب",
"Output": "الإخراج",
"Brightness": "السطوع",
"How far you see, the window and the HUD.": "إلى أي مدى ترى النافذة وشاشة HUD.",
"The sky and weather, lighting, textures and the image.": "السماء والطقس والإضاءة والقوام والصورة.",
"Chunk loading, culling and power use.": "تحميل القطع وإعدامها واستخدام الطاقة.",
"How frames reach the screen, and the performance overlay.": "كيفية وصول الإطارات إلى الشاشة وتراكب الأداء.",
"Brighter-than-white output on an HDR display.": "إخراج أكثر سطوعًا من اللون الأبيض على شاشة HDR.",
"Results for \"%s\"": "نتائج ل \"%s\"",
"No settings match.": "لا توجد إعدادات متطابقة.",
"horizon.title.not_affiliated": "لا ينتمي إلى Mojang أو Microsoft",
"horizon.extras.header.lighting": "الإضاءة",
"horizon.extras.header.third_person": "شخص ثالث",
"horizon.extras.header.particles": "الجسيمات",
"horizon.extras.header.inventory": "المخزون",
"horizon.extras.header.chat": "الدردشة",
"horizon.extras.header.multiplayer": "متعددة اللاعبين",
"horizon.extras.header.sound": "الصوت",
"horizon.extras.header.menus": "القوائم",
"horizon.extras.fullbright": "فولبرايت",
"horizon.extras.dynamic_lights": "أضواء ديناميكية",
"horizon.extras.own_name_tag": "علامة الاسم الخاصة",
"horizon.extras.hide_block_particles": "إخفاء جزيئات كسر الكتلة",
"horizon.extras.auto_reconnect": "إعادة الاتصال التلقائي",
"horizon.extras.ui_sound_reserve": "احتفظ بقنوات لأصوات واجهة المستخدم",
"horizon.extras.music_page": "صفحة الموسيقى",
"horizon.extras.mouse_tweaks": "الضوابط بتعديل الماوس",
"horizon.extras.mouse_tweaks_wheel": "عجلة التمرير تنقل العناصر",
"horizon.extras.sort": "أزرار الفرز",
"horizon.extras.sort_key": "مفتاح الفرز",
"horizon.extras.no_ai": "لا يوجد ذكاء اصطناعي",
"horizon.extras.agent_control": "مراقبة الوكيل",
"horizon.extras.discord_presence": "وجود الخلاف الغني",
"horizon.extras.splitscreen": "تقسيم الشاشة",
"horizon.extras.chat_animation": "دردشة سلسة",
"horizon.extras.chat_indicator": "مؤشر الرسالة",
"horizon.extras.wheel_cycles_settings": "عجلة التمرير تغير الإعدادات",
"horizon.extras.pack_browser": "متصفح حزمة الموارد",
"horizon.extras.fullbright.tooltip": "إظهار الكتل والكيانات في الضوء الكامل، حتى في المناطق المظلمة.",
"horizon.extras.dynamic_lights.tooltip": "تم حمل المشاعل ومصادر الضوء الأخرى أو إسقاطها أو إشعال النار فيها على الكتل المحيطة بها.",
"horizon.extras.own_name_tag.tooltip": "يظهر اسمك فوق رأسك بصيغة الغائب، كما يراه اللاعبون الآخرون. يكون مخفيًا عندما يخفي الخادم أسماء فريقك أو عندما تكون غير مرئي.",
"horizon.extras.hide_block_particles.tooltip": "يخفي جزيئات الحطام التي تنفجر من كتلة مكسورة وتتفتت من تلك التي قمت بتعدينها. وتبقى الجزيئات الأخرى.",
"horizon.extras.auto_reconnect.tooltip": "إعادة الانضمام إلى الخادم بعد انقطاع الاتصال أو إعادة التشغيل، وليس بعد ركلة أو حظر أبدًا.",
"horizon.extras.ui_sound_reserve.tooltip": "تترك الأصوات في العالم 16 قناة صوتية من أصل 247 قناة مجانية، لذا فإن طوفانًا منها (انفجار كبير) لا يمكنه إسكات نقرات القائمة.",
"horizon.extras.music_page.tooltip": "تحصل الموسيقى والأصوات على صفحة موسيقى...: قم بتشغيل أي مسار مضمن الآن، أو قم بإيقاف تشغيله حتى تتوقف اللعبة عن اختياره. لا شيء يتغير حتى تقوم بإيقاف تشغيل المسار.",
"horizon.extras.mouse_tweaks.tooltip": "يؤدي السحب الأيمن إلى وضع عنصر واحد في كل فتحة، بينما يقوم السحب الأيسر بتجميع العنصر المحفوظ، بينما يقوم Shift + السحب الأيسر بنقل كل فتحة.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "يرسل الجزء السفلي عنصرًا واحدًا من الفتحة إلى الجانب الآخر، بينما يقوم الجزء العلوي بإحضار عنصر واحد مرة أخرى.",
"horizon.extras.sort.tooltip": "يقوم بفرز المخزون والصناديق والبراميل وصناديق النهاية وصناديق الشولكر بترتيب علامات تبويب إبداعية، ودمج الأكوام.",
"horizon.extras.sort_key.tooltip": "فرز الجزء الموجود تحت المؤشر. انقر، ثم اضغط على مفتاح؛ الهروب يفككه.",
"horizon.extras.no_ai.tooltip": "يخفي ويوقف تشغيل كل ميزات الذكاء الاصطناعي: التحكم بالوكيل، Horizon-ctl وخوادم MCP. مفتاح المشغل أيضًا.",
"horizon.extras.agent_control.tooltip": "يمكن لوكيل الذكاء الاصطناعي الخاص بك لعب هذه اللعبة من خلال Horizon-ctl أو عميل MCP، على هذا الكمبيوتر فقط.",
"horizon.extras.discord_presence.tooltip": "يعرض Playing Horizon على ملف تعريف Discord الخاص بك أثناء تشغيل Discord: القوائم، أو اللاعب الفردي أو المتعدد، والبعد ووقت اللعب. لا يوجد أبدًا اسم عالمي أو عنوان خادم.",
"Calibrate...": "معايرة...",
"Calibrate Anyway...": "معايرة على أية حال...",
"Use Automatic Setup": "استخدم الإعداد التلقائي",
"Shortcut: %s": "الاختصار: %s",
"Next": "التالي",
"HDR Calibration %s/%s: %s": "معايرة HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "لتعيين الصور الثلاثة أدناه بملء الشاشة، صورة اختبارية واحدة في كل مرة.",
"A key that opens this screen from the game. Not bound at first.": "المفتاح الذي يفتح هذه الشاشة من اللعبة. غير ملزمة في البداية.",
"Your display didn't say how bright it gets: calibrate to set it up.": "لم توضح شاشتك مدى سطوعها: قم بالمعايرة لإعدادها.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "تم إعداده بالفعل للعرض الخاص بك. قم بالمعايرة فقط إذا كان تقرير HDR يبدو خاطئًا.",
"Puts Peak Brightness and Paper White back to your display's setup.": "يعيد ذروة السطوع والورق الأبيض إلى إعداد شاشتك.",
"Set up for your display. Leave on Auto unless it looks wrong.": "قم بإعداد العرض الخاص بك. اتركه على الوضع التلقائي ما لم يبدو خاطئًا.",
"Lower it until the logo just disappears into the black.": "قم بخفضه حتى يختفي الشعار باللون الأسود.",
"Raise it until the logo just disappears into the bright square.": "ارفعه حتى يختفي الشعار في المربع المشرق.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "اجعل البطاقة بيضاء اللون بحيث تكون مريحة عند النظر إليها لفترة طويلة، في الضوء الذي تلعب فيه.",
"horizon.splitscreen.profiles.forget": "ننسى",
"horizon.splitscreen.profiles.create": "إنشاء",
"horizon.splitscreen.profiles.you": " - أنت",
"horizon.splitscreen.profiles.forget_named": "ننسى %s",
"horizon.splitscreen.profiles.previous": "السابق",
"horizon.splitscreen.profiles.next": "التالي",
"horizon.splitscreen.profiles.new_button": "ملف تعريف جديد...",
"horizon.splitscreen.profiles.forget_title": "هل نسيت الملف الشخصي؟",
"horizon.splitscreen.profiles.forget_leaves": "فإنه يترك قائمة هذا المثيل.",
"horizon.splitscreen.profiles.forget_kept": "يتم الاحتفاظ بالتقدم العالمي والإعدادات.",
"horizon.splitscreen.profiles.new_title": "ملف تعريف جديد",
"horizon.splitscreen.profiles.name_hint": "الاسم (من 3 إلى 16 حرفًا ورقمًا _)",
"horizon.splitscreen.profiles.switch_title": "تبديل الملف الشخصي",
"horizon.splitscreen.profiles.offline_note": "تنتمي الملفات الشخصية غير المتصلة بالإنترنت إلى هذه الحالة",
"horizon.splitscreen.profiles.move": "تحرك حيث تلعب",
"horizon.splitscreen.profiles.move_place": "تحرك حيث تلعب: %s",
"horizon.friends.title": "العب مع الأصدقاء",
"horizon.friends.hint": "يقوم الأصدقاء بلصق الرمز بعد زر الأصدقاء على شاشة العنوان، أو في الاتصال المباشر، أو على صفحة التشغيل الخاصة بالمشغل.",
"horizon.friends.copied": "منقول",
"horizon.friends.copy_code": "نسخ الرمز",
"horizon.friends.stop_sharing": "توقف عن المشاركة",
"horizon.friends.none_yet": "لا يوجد أصدقاء هنا بعد.",
"horizon.friends.here_now": "هنا الآن: %s",
"horizon.friends.share_intro": "شارك هذا العالم خلف رمز الدعوة: فهو يفتح على شبكة LAN، وينضم الأصدقاء على الإنترنت باستخدام الرمز. لا يوجد خادم خاص بنا متورط.",
"horizon.friends.start_sharing": "ابدأ المشاركة",
"horizon.friends.troubleshooting": "استكشاف الأخطاء وإصلاحها...",
"horizon.friends.stopped": "تم إيقاف مشاركة رمز الدعوة.",
"horizon.friends.short_code_changed": "استمر شخص ما في تجربة رمز قصير خاطئ، لذلك تغير.",
"horizon.friends.wrong_code": "حاول شخص ما رمزا خاطئا.",
"horizon.friends.code_ready": "رمز الدعوة الخاص بك جاهز: قائمة الألعاب، العب مع الأصدقاء. انسخها وأرسلها إلى أصدقائك.",
"horizon.friends.share_failed": "لا يمكن مشاركة العالم: %s",
"horizon.friends.finding_route": "التعرف على كيفية وصول الأصدقاء إليك...",
"horizon.osk.shift": "التحول",
"horizon.osk.space": "الفضاء",
"horizon.osk.delete": "حذف",
"horizon.osk.type": "اكتب",
"horizon.osk.symbols": "الرموز",
"horizon.osk.close": "إغلاق",
"horizon.extras.chat_animation.tooltip": "ينزلق خط الدردشة الجديد من اليسار بينما يتلاشى للداخل، بدلاً من أن يظهر كاملاً.",
"horizon.extras.chat_indicator.tooltip": "الشريط الملون الموجود على يسار خط الدردشة.",
"horizon.extras.wheel_cycles_settings.tooltip": "عجلة التمرير فوق زر في الإعدادات تغير قيمته، كما هو الحال في الفانيليا. مع إيقاف التشغيل، تقوم العجلة بتمرير القائمة فقط.",
"horizon.extras.pack_browser.tooltip": "تحديد حزم الموارد يحصل على زر \"إضافة\" الذي يبحث في Modrinth ويقوم بتثبيت حزمة في مجلد الحزم الخاص بك. مع إيقاف التشغيل، لن تتصل اللعبة أبدًا بـ Modrinth.",
"horizon.extras.splitscreen.tooltip": "في عالم ما، يمكن لوحدة تحكم أخرى الانضمام كلاعب جديد: اضغط على A عندما يُطلب منك ذلك، ثم اختر ملفًا شخصيًا ومكان اللعب.",
"horizon.splitscreen.setup.monitor": "مراقب %s",
"horizon.splitscreen.setup.monitor_unknown": "شاشة",
"horizon.splitscreen.setup.window": "نافذة %s",
"horizon.splitscreen.setup.new_window": "نافذة جديدة",
"horizon.splitscreen.setup.fill_monitor": "املأ %s",
"horizon.splitscreen.setup.done": "تم",
"horizon.splitscreen.setup.on": "على",
"horizon.splitscreen.setup.off": "إيقاف",
"horizon.splitscreen.setup.player": "اللاعب %s",
"horizon.splitscreen.setup.revert": "العودة",
"horizon.splitscreen.setup.apply": "تطبيق",
"horizon.splitscreen.setup.warning.empty_main": "لا أحد يلعب في النافذة 1: إنه يخفي",
"horizon.splitscreen.setup.warning.empty_close": "لا أحد يلعب في النافذة %s: يتم إغلاقها عند تطبيق",
"horizon.splitscreen.setup.warning.empty_new": "لا أحد يلعب في النافذة %s: لن يتم فتحها",
"horizon.splitscreen.setup.warning.on_main": "النافذة %s تملأ شاشة النافذة 1: يلعب لاعبوها في النافذة 1",
"horizon.splitscreen.setup.warning.unplugged": "شاشة النافذة %s غير متصلة: يتم تشغيل مشغلاتها في النافذة 1",
"horizon.splitscreen.setup.status.off": "تقسيم الشاشة معطل.",
"horizon.splitscreen.setup.status.enable_hint": "قم بتشغيله، ثم اضغط على زر على وحدة تحكم أخرى في العالم.",
"horizon.splitscreen.setup.status.join_hint": "اضغط على A للانضمام واختيار ملف التعريف.",
"horizon.splitscreen.setup.status.idle": "تقسيم الشاشة خاملاً",
"horizon.splitscreen.setup.status.alone": "لا يوجد لاعبين محليين آخرين.",
"horizon.splitscreen.setup.status.solo_controls": "لم تتغير عناصر التحكم الفردية وملاحظات وحدة التحكم.",
"horizon.splitscreen.setup.window_empty": "النافذة %s: فارغة",
"horizon.splitscreen.setup.note.select": "انقر فوق أحد اللاعبين، أو اسحبه إلى نافذة أو شاشة.",
"horizon.splitscreen.setup.note.apply": "تقدم للعب بهذه الطريقة، ثم ارجع للتراجع.",
"horizon.splitscreen.setup.note.place": "أين يجب أن يلعب %s؟",
"horizon.splitscreen.setup.this_player": "هذا اللاعب",
"horizon.splitscreen.setup.switch_disabled": "يتم إيقاف تشغيل الشاشة المنقسمة بمجرد مغادرة الآخرين (قائمة الإيقاف المؤقت الخاصة بهم).",
"horizon.splitscreen.setup.window_place": "النافذة %s (%s من %s)",
"horizon.splitscreen.setup.names": "الأسماء",
"horizon.splitscreen.setup.identify": "تحديد",
"horizon.splitscreen.setup.layout.two": "2 المشاركة: %s",
"horizon.splitscreen.setup.layout.three": "3 المشاركة: %s",
"horizon.splitscreen.setup.layout.two.0": "تلقائي",
"horizon.splitscreen.setup.layout.two.1": "جنبا إلى جنب",
"horizon.splitscreen.setup.layout.two.2": "أعلى وأسفل",
"horizon.splitscreen.setup.layout.three.0": "واحدة واسعة في الأعلى",
"horizon.splitscreen.setup.layout.three.1": "واحد طويل القامة على اليسار",
"horizon.splitscreen.setup.layout.three.2": "جنبا إلى جنب",
"horizon.splitscreen.setup.help.player": "انقر أو اسحب %s لتحريكها.",
"horizon.splitscreen.setup.help.layout_auto": "%s (جنبًا إلى جنب عندما تكون النافذة واسعة). اضغط للتغيير.",
"horizon.splitscreen.setup.help.layout": "%s. اضغط للتغيير.",
"horizon.splitscreen.setup.help.switch": "تقسيم الشاشة لهذا الكمبيوتر، أو إيقاف تشغيله.",
"horizon.splitscreen.setup.help.identify": "تُظهر طريقة عرض كل لاعب النافذة التي يوجد بها.",
"horizon.splitscreen.setup.help.names": "اسم كل لاعب في زاوية رؤيته.",
"horizon.splitscreen.setup.help.revert": "التراجع عن التغييرات التي لم يتم تطبيقها بعد.",
"horizon.splitscreen.setup.help.apply": "العب بالطريقة التي تظهر بها الخريطة.",
"horizon.splitscreen.setup.help.unchanged": "لم يتغير شيء بعد.",
"horizon.splitscreen.setup.give_up_pad": "التخلي عن الوسادة",
"horizon.splitscreen.prompt.controller_connected": "وحدة التحكم متصلة",
"horizon.splitscreen.prompt.controller_disconnected": "وحدة التحكم مفصولة",
"horizon.splitscreen.prompt.controller_disconnected.hint": "أعد توصيله، أو اضغط على زر على آخر",
"horizon.splitscreen.prompt.join.one": "%s يريد الانضمام",
"horizon.splitscreen.prompt.join.many": "%s وحدات التحكم تريد الانضمام",
"horizon.splitscreen.prompt.join.yes": "انضم",
"horizon.splitscreen.prompt.join.as_p1": "العب بشخصية P1",
"horizon.splitscreen.prompt.join.no": "لا",
"horizon.splitscreen.prompt.lost": "%s: تم فصل وحدة التحكم",
"horizon.splitscreen.prompt.lost.use_another": "استخدم آخر",
"horizon.splitscreen.prompt.lost.leave": "غادر",
"horizon.splitscreen.setup.help.give_up_pad": "تتوقف وحدة التحكم الخاصة بك عن قيادتك ويُعرض عليك الانضمام (أ).",
"horizon.splitscreen.setup.note.held_at": "%s إلى %s: يسقطها A هنا، ويعيدها B.",
"horizon.splitscreen.setup.note.held": "%s تم الالتقاط: يسار/يمين يختار المكان، يسقط A، ويعيده B.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "يخفي النماذج خلف الكتل المعتمة. غير متزامن يتحقق من خارج الموضوع؛ علامة الكيانات.",
"Block Entities": "حظر الكيانات",
"Shulker Boxes": "صناديق شوكر",
"Reuses settled boxes; lid movement stays animated.": "إعادة استخدام الصناديق المستقرة؛ تبقى حركة الغطاء متحركة.",
"Signs": "علامات",
"Reuses sign text until text, light, or font changes.": "يعيد استخدام نص التوقيع حتى يتغير النص أو الضوء أو الخط.",
"Decorated Pots": "الأواني المزخرفة",
"Reuses still pots; break wobble and sherd changes redraw.": "إعادة استخدام الأواني الثابتة؛ كسر تمايل وتغييرات الكسر إعادة رسم.",
"Bells": "أجراس",
"Reuses still bells; ringing stays animated.": "إعادة استخدام الأجراس الثابتة؛ يبقى الرنين متحركًا.",
"Chests": "الصناديق",
"Reuses closed chests; moving lids stay animated.": "إعادة استخدام الصناديق المغلقة؛ الأغطية المتحركة تبقى متحركة.",
"Banners": "لافتات",
"Reuses poles; flags and pattern layers still update.": "إعادة استخدام الأعمدة؛ لا يزال يتم تحديث الأعلام وطبقات الأنماط.",
"Copper Golem Statues": "تماثيل النحاس جوليم",
"Reuses statue geometry until its block state changes.": "يعيد استخدام هندسة التمثال حتى تتغير حالة الكتلة الخاصة به.",
"Leaf Culling": "إعدام الأوراق",
"Every leaf face. Vanilla's fancy leaves.": "وجه كل ورقة. أوراق الفانيليا الفاخرة.",
"Distant Plant Dither": "ثبات النبات البعيد",
"Keeps fine plants in high-res packs from fading far away.": "يحافظ على النباتات الجميلة في عبوات عالية الدقة من البهتان بعيدًا.",
"Screenshot Quality": "جودة لقطة الشاشة",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "لقطات شاشة F2 بهذا دقة اللعبة عدة مرات. أعلى يستغرق وقتا أطول.",
"Frame Generation": "جيل الإطار",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "يُظهر إطارًا مكوّنًا بين كل إطارين حقيقيين: أكثر سلاسة وتأخرًا قليلًا.",
"Shadow Cache": "ذاكرة التخزين المؤقت الظل",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "يعيد استخدام خريطة ظل الحزمة عندما تتطابق الشمس والعرض والعالم. إيقاف بشكل افتراضي.",
"Find BlueMap": "ابحث عن بلو ماب",
"On servers, finds their BlueMap web map for far terrain. Next join.": "على الخوادم، يتم العثور على خريطة الويب BlueMap الخاصة بهم للتضاريس البعيدة. الانضمام التالي.",
"BlueMap Radius": "نصف قطر الخريطة الزرقاء",
"How far around you BlueMap tiles are downloaded. Next join.": "إلى أي مدى يتم تنزيل بلاط BlueMap من حولك. الانضمام التالي.",
"Synchronous": "متزامن",
"Asynchronous": "غير متزامن",
"Solid": "صلب",
"Solid Aggressive": "العدوانية الصلبة",
"Hollow": "جوفاء",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "يتم رسم الأوراق بالكامل داخل المظلة بشكل صلب: طبقات شفافة أقل.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "الأوراق ذات جانب واحد مفتوح ترسم بشكل صلب أيضًا: حواف أسرع وأكثر كثافة.",
"Drops faces between leaves: faster, but gaps look through.": "يسقط الوجوه بين الأوراق: بشكل أسرع، لكن الفجوات تبدو من خلالها.",
"Hotbar: Previous Slot": "Hotbar: الفتحة السابقة",
"Hotbar: Next Slot": "Hotbar: الفتحة التالية",
"horizon.agent.connected.one": "متصل: اتصال وكيل واحد",
"horizon.agent.connected.many": "متصل: %s اتصالات الوكيل",
"horizon.agent.connected.one_ago": "متصل: اتصال وكيل واحد، آخر أمر %s مضت",
"horizon.agent.connected.many_ago": "متصل: %s اتصالات الوكيل، آخر أمر %s مضت",
"horizon.agent.waiting": "في انتظار وكيل.",
"horizon.agent.off": "إيقاف: لا يمكن للعملاء الوصول إلى هذه اللعبة.",
"horizon.agent.session_opened": "تم فتحه لهذه الجلسة بواسطة البرنامج الذي بدأ اللعبة.",
"horizon.agent.socket": "المقبس: %s (انسخ التفاصيل لمساره الكامل)",
"horizon.agent.local_only": "محلي فقط: الوكلاء على هذا الكمبيوتر، وليس الشبكة أبدًا.",
"%s nits": "%s القمل",
"Auto (%s nits)": "تلقائي (%s شمعة في المتر المربع)",
"%.2f nits": "%.2f القمل",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "يخفي النماذج خلف الكتل المعتمة. تحديث الكيانات مع حزم مخصصة.",
"horizon.extras.repro_keys": "تسجيل الاختصارات",
"horizon.extras.repro_keys.tooltip": "يقوم F3+R ببدء التسجيل النسخي أو إيقافه ويقوم F3+E بكتابة الحزمة الخاصة به. متوقف بشكل افتراضي: تسجيل ينسخ العالم.",
"horizon.agent.record_session": "ابدأ جلسة التسجيل",
"horizon.agent.record_stop": "إيقاف جلسة التسجيل",
"horizon.agent.export_repro": "تصدير حزمة النسخ المتماثل",
"horizon.repro.started": "بدأ التسجيل؛ F3+R يوقفه ويكتب الحزمة.",
"horizon.repro.stopped": "توقف التسجيل: %s",
"horizon.repro.bundle": "حزمة النسخ: %s",
"horizon.repro.error": "استنساخ: %s",
"horizon.repro.no_recording": "لا يوجد تسجيل للتصدير حتى الآن.",
"horizon.repro.replay_started": "بدأت إعادة التشغيل: %s",
"horizon.pack.addInfo": "إضافة حزمة موارد من Modrinth",
"horizon.pack.addWorking": "يسأل مودرنث...",
"horizon.pack.addNone": "لم يتم العثور على حزم الموارد",
"horizon.pack.addTitle": "أضف حزمة الموارد",
"horizon.pack.install": "تثبيت",
"horizon.pack.installing": "التثبيت",
"horizon.pack.installed": "تم التثبيت",
"horizon.pack.search": "بحث",
"horizon.pack.searchHint": "بحث مودرينث...",
"horizon.pack.filters": "المرشحات",
"horizon.pack.filter.realistic": "واقعية",
"horizon.pack.filter.simplistic": "تبسيط",
"horizon.pack.filter.vanillaLike": "تشبه الفانيليا",
"horizon.pack.addFailed": "لا يمكن إضافة الحزمة: %s",
"horizon.extras.header.cosmetics": "مستحضرات التجميل",
"horizon.extras.cosmetics_mine": "مستحضرات التجميل الخاصة بي",
"horizon.extras.cosmetics_mine.tooltip": "مخفي: يراك كل لاعب في Horizon بدون مستحضرات التجميل الخاصة بك، بما في ذلك أنت. نفس مفتاح خزانة المشغل.",
"horizon.extras.cosmetics_others": "مستحضرات التجميل للاعبين الآخرين",
"horizon.extras.cosmetics_others.tooltip": "ما يرتديه لاعبو Horizon الآخرون: كل شيء، فقط أنماط بطاقة الاسم الخاصة بهم، أو لا شيء. مخفيًا، اللعبة لا تسأل أبدًا عما يرتدونه.",
"horizon.extras.cosmetics_auras": "هالات ومسارات الآخرين",
"horizon.extras.cosmetics_auras.tooltip": "الهالات والمسارات التي يرتديها اللاعبون الآخرون. إيقاف بشكل افتراضي.",
"horizon.extras.cosmetics.shown": "معروض",
"horizon.extras.cosmetics.hidden": "مخفي",
"horizon.extras.cosmetics.name_tags": "علامات الاسم فقط"
}
@@ -0,0 +1,473 @@
{
"horizon.options.controller": "Nəzarətçi",
"horizon.options.vibration": "Vibrasiya",
"horizon.options.adaptive_triggers": "Adaptiv tətiklər",
"horizon.options.lightbar": "İşıq çubuğu",
"horizon.options.look_sensitivity": "Baxış həssaslığı",
"horizon.options.invert_x": "Tərs baxış X",
"horizon.options.invert_y": "Tərs baxış Y",
"horizon.options.sneak_button": "Gizlənmə düyməsi",
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
"horizon.options.look_deadzone": "Baxış ölü zonası",
"horizon.options.button_glyphs": "Düymə Qlifləri",
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
"horizon.options.chat_delay": "Söhbət Gecikməsi",
"horizon.options.lightbar.health": "Sağlamlıq",
"horizon.options.lightbar.dimension": "Ölçü",
"horizon.options.glyphs.auto": "Avtomatik",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Göstərici",
"horizon.options.keyboard.auto": "Avtomatik",
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"horizon.options.search": "Axtarış parametrləri...",
"horizon.pages.mods": "Modlar",
"horizon.pages.shader_packs": "Shader Paketləri",
"horizon.pages.shader_options": "Shader Seçimləri",
"horizon.pages.controller": "Nəzarətçi Parametrləri",
"horizon.pages.controller_binds": "Nəzarətçi Bağlayır",
"horizon.pages.extras": "Horizon Əlavələr",
"horizon.pages.settings": "Horizon Parametrlər",
"horizon.pages.agent": "AI agenti",
"horizon.split.leave": "tərk et",
"horizon.pages.split_settings": "Splitscreen Parametrləri",
"horizon.pages.split_profiles": "Kim oynayır?",
"horizon.pages.split_setup": "Splitscreen Quraşdırma",
"horizon.pages.music": "Musiqi",
"horizon.pages.music.menu": "Menyu",
"horizon.pages.music.overworld": "Yerüstü dünya",
"horizon.pages.music.creative": "Yaradıcı",
"horizon.pages.music.underwater": "Sualtı",
"horizon.pages.music.end": "Son",
"horizon.pages.music.boss": "patron",
"horizon.pages.music.credits": "Kreditlər",
"horizon.pages.music.overworld_biomes": "Yerüstü biomlar",
"horizon.pages.music.nether_biomes": "Nether biomları",
"horizon.pages.music.end_biomes": "Biomları bitir",
"horizon.pages.music.other": "Digər musiqi",
"horizon.pages.music.empty": "Musiqi parçaları yoxdur: oyunun səsləri yüklənmir.",
"horizon.pages.music.tooltip": "Vəziyyətə görə quraşdırılmış treklər: birini indi oynayın və ya hər hansı birini söndürün ki, oyun onu seçməyi dayandırsın.",
"horizon.pages.settings.tooltip": "Horizon öz parametrləri: qrafika, nəzarətçilər, əlavələr və modlar.",
"horizon.pages.graphics.tooltip": "Qrafika, sadəcə olaraq: hər parametr nəyə başa gəldiyini bildirir.",
"horizon.pages.graphics_advanced.tooltip": "Hər bir qrafik parametri, Horizon daxil edilir, hər birinin dəyəri ilə.",
"horizon.pages.controller.tooltip": "Nəzarətçilər: onların düymələri, çubuqları, vibrasiya və menyu naviqasiyası.",
"horizon.pages.extras.tooltip": "Könüllü əlavələr, hamısı defolt olaraq söndürülür: siz birini yandırana qədər oyun vanil olaraq qalır.",
"horizon.pages.agent.tooltip": "Öz AI agentinizin oynamasına icazə verin: bu kompüterdə horizon-ctl və MCP müştəriləri.",
"horizon.pages.split_profiles.tooltip": "Kim oynayır: bu oyun qovluğunda saxlanılan profil.",
"horizon.pages.split_setup.tooltip": "Pəncərələr və monitorlar və kim harada oynayır.",
"horizon.pages.shader_packs.tooltip": "shaderpacks qovluğundan şader paketləri: Horizon öz və shaders/formatında olanlar. Bir dəfə qaçır.",
"horizon.pages.mods.tooltip": "Horizon-mods qovluğundan Horizon üçün hazırlanmış modlar. Hər biri sandboxda işləyir və siz onu yandırana qədər sönür.",
"horizon.pages.open.graphics": "Qrafika...",
"horizon.pages.open.graphics_advanced": "Qabaqcıl Qrafika...",
"horizon.pages.open.shader_packs": "Shader Paketləri...",
"horizon.pages.open.controller": "Nəzarətçi Parametrləri...",
"horizon.pages.open.split_setup": "Ayrılmış ekran...",
"horizon.pages.open.extras": "Əlavələr...",
"horizon.pages.open.mods": "Modlar...",
"horizon.pages.open.horizon_graphics": "Horizon Qrafika...",
"horizon.pages.open.controller_binds": "Nəzarətçi Bağlayır...",
"horizon.pages.open.horizon_settings": "Horizon Parametrlər...",
"horizon.pages.open.music": "Musiqi...",
"horizon.mods.empty": "Mod yoxdur: .hmod fayllarını horizon-mods qovluğuna qoyun",
"horizon.mods.stopped": " (Dayandı)",
"horizon.mods.not_built": " (tikilməyib)",
"horizon.mods.settings": "Parametrlər",
"horizon.mods.in_world": " (Dəyişmək üçün dünyanı tərk et)",
"horizon.mods.open_folder": "Mod Qovluğunu açın",
"horizon.shader_packs.empty": "Şeyder paketləri yoxdur: onları shaderpacks qovluğuna qoyun",
"horizon.shader_options.open": "Shader Seçimləri...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Şeyder paketinin seçimləri oxunur...",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
"horizon.multiplayer.cancel_reconnecting": "Yenidən qoşulmanı ləğv edin",
"horizon.multiplayer.join_friend": "Dosta Qoşulun",
"horizon.multiplayer.invite_code": "Dəvət Kodu",
"horizon.multiplayer.relay": "Rele (isteğe bağlı)",
"Preset": "Əvvəlcədən təyin",
"LOD Distance": "LOD məsafəsi",
"Far Detail": "Uzaq Detal",
"Cave Culling": "Mağaraların kəsilməsi",
"Entity Culling": "Müəssisə Culling",
"Chunk Updates": "Parça Yeniləmələri",
"Upload Budget": "Büdcə yükləyin",
"Sky": "Göy",
"Fog": "Duman",
"Weather": "Hava",
"Animated Textures": "Animasiyalı Dokular",
"Present Mode": "İndiki rejim",
"Worker Threads": "İşçi ipləri",
"Resolution Scale": "Qətnamə Ölçüsü",
"Upscaling": "Təkmilləşdirmə",
"Paper White": "Kağız Ağ",
"Peak Brightness": "Pik Parlaqlıq",
"Black Level": "Qara Səviyyə",
"UI Brightness": "UI Parlaqlığı",
"Battery Mode": "Batareya rejimi",
"Graphics API": "Qrafik API",
"Translucent Batching": "Şəffaf Batching",
"Performance Overlay": "Performans örtüyü",
"Full Detail Distance": "Tam Detal Məsafə",
"On": "Aktiv",
"Off": "Söndür",
"Unlimited": "Limitsiz",
"Auto": "Avtomatik",
"Custom": "Xüsusi",
"Performance": "Performans",
"Balanced": "Balanslı",
"Quality": "Keyfiyyət",
"Ultra Performance": "Ultra Performans",
"Native": "Yerli",
"Full": "Tam",
"Reduced": "Azaldılmış",
"Efficient": "Səmərəli",
"Precise": "Dəqiq",
"Auto (VSync)": "Avtomatik (VSync)",
"FIFO": "FİFO",
"Mailbox": "Poçt qutusu",
"Immediate": "Dərhal",
"FPS and Graph": "FPS və Qrafik",
"No cost": "Xərc yoxdur",
"Low cost": "Aşağı qiymət",
"Medium cost": "Orta qiymət",
"High cost": "Yüksək qiymət",
"Saves time": "Vaxta qənaət edir",
"Graphics": "Qrafika",
"Done": "Bitdi",
"This feature is not yet available in Horizon.": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
"Your display or system doesn't offer HDR output.": "Ekranınız və ya sisteminiz HDR çıxışı təklif etmir.",
"Turn HDR on to use it.": "İstifadə etmək üçün HDR-ni yandırın.",
"Your GPU or graphics API doesn't support it.": "GPU və ya qrafik API onu dəstəkləmir.",
"%s chunks": "%s parçalar",
"%s per frame": "%s hər çərçivə üçün",
"%s MiB/frame": "%s MiB/çərçivə",
"%s KiB/frame": "%s KiB/çərçivə",
"Auto (%s)": "Avtomatik (%s)",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Tam detallı ərazi diapazonu. LOD məsafəsi yerli dünyaları daha da uzada bilər.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerli dünyalarda kobud uzaq ərazi. İnteqrasiya edilmiş GPU-lar 128 hissədə dayanır.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Azaldılmış ərazini daha qaba çəkir: bu inteqrasiya olunmuş GPU-da daha sürətli.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Azaldılmış ərazini daha qaba çəkir, GPU-lar üçün isə yavaşlayır.",
"Field of view. Wider shows more terrain at once.": "Baxış sahəsi. Daha geniş bir anda daha çox ərazini göstərir.",
"Caps frames per second; a cap saves power and heat.": "Saniyədə kadrların sayı; qapaq enerjiyə və istiliyə qənaət edir.",
"Waits for the display's refresh: no tearing, a little latency.": "Ekranın təzələnməsini gözləyir: cırılma, bir az gecikmə.",
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Qapalı hissələrin arxasında gizlənmiş ərazini ötür. Sıxılma çox baha başa gəlir.",
"Hides entities behind opaque blocks.": "Obyektləri qeyri-şəffaf blokların arxasında gizlədir.",
"No entity occlusion culling.": "Heç bir obyektin okklyuziyası məhv edilmir.",
"CPU occlusion test runs this frame.": "CPU tıxanma testi bu çərçivəni idarə edir.",
"Occlusion checks run on a worker thread.": "Tıxanma yoxlanışı işçi ipində aparılır.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed bölmələr hər çərçivəyə yüklənir. Yükləyərkən aşağı daha hamar olur.",
"Soft shadows in corners. Costs chunk building time only.": "Künclərdə yumşaq kölgələr. Yalnız tikinti vaxtı xərcləri.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzaq ərazilər üçün blok dokuların daha kiçik nüsxələri: daha az parıltı.",
"Blends grass and water colours across biomes when building chunks.": "Parçalar qurarkən biomlar arasında ot və su rənglərini qarışdırır.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Bulud təbəqəsi: sönük, düz (sürətli) və ya 3D (fantastik).",
"How far clouds reach, in chunks.": "Buludlar nə qədər uzağa çatır, parça-parça.",
"Draws the sky, sun, moon and stars.": "Göyü, günəşi, ayı və ulduzları çəkir.",
"Distance fog. Off shows the edge of the loaded world.": "Məsafə dumanı. Off yüklənmiş dünyanın kənarını göstərir.",
"Draws rain and snow.": "Yağış və qar çəkir.",
"How far rain and snow fall around you.": "Ətrafınıza yağış və qar nə qədər yağır.",
"How many particles effects spawn.": "Nə qədər hissəcik effektləri kürü verir.",
"Round shadows under mobs and items.": "Kütlələrin və əşyaların altında dairəvi kölgələr.",
"Leaves with see-through gaps. Off draws them solid.": "Yarpaqları göz qabağındadır. Off onları möhkəm çəkir.",
"Sorts water and glass more exactly behind each other.": "Suyu və şüşəni bir-birinin arxasında daha dəqiq şəkildə sıralayır.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Hansı hissənin yenidən qurulması çərçivəni gözləyir: heç biri, yaxınlıqda və ya sizin.",
"Animates water, lava, fire and other moving textures.": "Su, lava, od və digər hərəkətli dokuları canlandırır.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Kəsik kənarları hamarlayır. FXAA: daha yumşaq. TAA: ən hamar, hərəkətli şeylər cığır çəkə bilər.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Dünyanı daha kiçik edir və TAA ilə yenidən qurur. Daha sürətli, daha yumşaq.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR displeydə HDR çıxışı: lava, yanğın və günəş işığı daha parlaq parlayır.",
"How frames reach the screen while VSync is off.": "VSync söndürüldükdə çərçivələr ekrana necə çatır.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Növbəti kadra qədər səmərəli yuxular; Vanil kimi dəqiq spinlər.",
"Fills the screen (borderless).": "Ekranı doldurur (sərhədsiz).",
"Brightens dark places.": "Qaranlıq yerləri işıqlandırır.",
"Lowers the frame rate when minimised, or idle too.": "Minimumlaşdırıldıqda və ya boş olduqda kadr sürətini azaldır.",
"Sways the view while walking.": "Gəzərkən mənzərəni yelləyir.",
"Fades newly loaded chunks in.": "Yeni yüklənmiş parçaları sönür.",
"Darkens the screen's edges.": "Ekranın kənarlarını qaraldır.",
"Shows when the world saves.": "Dünyanın nə vaxt xilas etdiyini göstərir.",
"Shows attack cooldown.": "Hücumun soyumasını göstərir.",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS qapağı (menyularda 30 və ya diqqətsiz), işçilərin yarısı: daha az güc.",
"Threads that generate, light and mesh chunks. Next start.": "Yaradan iplər, yüngül və mesh parçaları. Növbəti başlanğıc.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonra uğursuz olduğu DirectX 12 (Windows) və ya OpenGL (Linux). Növbəti başlanğıc.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Bir buferdə su və şüşə: daha az çəkiliş zəngi, bir az daha çox VRAM. Növbəti başlanğıc.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Hər kadra GPU-ya göndərilən mesh datası. Yükləyərkən aşağı daha hamar olur.",
"Frame rate and frame times over the game, with a graph.": "Qrafik ilə oyun üzrə çərçivə sürəti və kadr vaxtları.",
"horizon.multiplayer.finding_friend": "Dostunuzun dünyasını tapmaq...",
"horizon.multiplayer.not_signed_in": "Bu server onlayn rejimdədir. Qoşulmaq üçün Horizon başlatma cihazında Microsoft hesabınızla daxil olun.",
"horizon.multiplayer.invalid_address": "Yanlış server ünvanı: %s",
"horizon.multiplayer.connected_friend_direct": "Dostunuza birbaşa qoşulun...",
"horizon.multiplayer.connected_friend_relay": "Rele vasitəsilə dostunuza qoşulun...",
"Advanced": "Qabaqcıl",
"Undo": "Geri alın",
"Search options": "Axtarış seçimləri",
"Shortcut": "Qısayol",
"Distance": "Məsafə",
"Display": "Ekran",
"Interface": "İnterfeys",
"Sky and weather": "Səma və hava",
"Lighting and entities": "İşıqlandırma və obyektlər",
"Textures": "Dokular",
"Image": "Şəkil",
"Chunk loading": "Parça yükləmə",
"Power": "Güc",
"Frames": "Çərçivələr",
"Output": "Çıxış",
"Brightness": "Parlaqlıq",
"How far you see, the window and the HUD.": "Nə qədər görürsən, pəncərə və HUD.",
"The sky and weather, lighting, textures and the image.": "Səma və hava, işıqlandırma, faktura və görüntü.",
"Chunk loading, culling and power use.": "Parçaların yüklənməsi, kəsilməsi və enerji istifadəsi.",
"How frames reach the screen, and the performance overlay.": "Çərçivələrin ekrana necə çatması və performans örtüyü.",
"Brighter-than-white output on an HDR display.": "HDR displeydə ağdan daha parlaq çıxış.",
"Results for \"%s\"": "\"%s\" üçün nəticələr",
"No settings match.": "Heç bir parametr uyğun gəlmir.",
"horizon.title.not_affiliated": "Mojang və ya Microsoft ilə əlaqəli deyil",
"horizon.extras.header.lighting": "İşıqlandırma",
"horizon.extras.header.third_person": "Üçüncü şəxs",
"horizon.extras.header.particles": "hissəciklər",
"horizon.extras.header.inventory": "İnventar",
"horizon.extras.header.chat": "Söhbət",
"horizon.extras.header.sound": "Səs",
"horizon.extras.header.menus": "Menyular",
"horizon.extras.fullbright": "Tam parlaq",
"horizon.extras.dynamic_lights": "Dinamik işıqlar",
"horizon.extras.own_name_tag": "Öz ad etiketi",
"horizon.extras.hide_block_particles": "Blok qırılan hissəcikləri gizlədin",
"horizon.extras.auto_reconnect": "Avtomatik yenidən qoşulun",
"horizon.extras.ui_sound_reserve": "UI səsləri üçün kanalları saxlayın",
"horizon.extras.music_page": "Musiqi səhifəsi",
"horizon.extras.mouse_tweaks": "Mouse Tweaks nəzarətləri",
"horizon.extras.mouse_tweaks_wheel": "Sürüşdürmə çarxı elementləri hərəkət etdirir",
"horizon.extras.sort": "Çeşidləmə düymələri",
"horizon.extras.sort_key": "Sıralama açarı",
"horizon.extras.no_ai": "AI yoxdur",
"horizon.extras.agent_control": "Agent Nəzarəti",
"horizon.extras.chat_animation": "Rahat söhbət",
"horizon.extras.chat_indicator": "Mesaj göstəricisi",
"horizon.extras.wheel_cycles_settings": "Sürüşdürmə çarxı parametrləri dəyişir",
"horizon.extras.pack_browser": "Resurs paketi brauzeri",
"horizon.extras.fullbright.tooltip": "Blokları və obyektləri hətta qaranlıq yerlərdə də tam işıqda göstərin.",
"horizon.extras.dynamic_lights.tooltip": "Məşəllər və digər işıq mənbələri ətrafdakı blokları tutmuş, yerə atmış və ya odda yandırır.",
"horizon.extras.own_name_tag.tooltip": "Başqa oyunçuların gördüyü kimi adınızı başınızın üstündə üçüncü şəxsdə göstərir. Server komandanızın adlarını gizlətdikdə və ya siz görünməz olduğunuzda gizlidir.",
"horizon.extras.hide_block_particles.tooltip": "Sınıq blokdan qopan və mənimsədiyiniz blokdan parçalanan zibil hissəciklərini gizlədir. Digər hissəciklər qalır.",
"horizon.extras.auto_reconnect.tooltip": "Heç bir zərbə və ya qadağadan sonra itən əlaqə və ya yenidən başladıqdan sonra serverə yenidən qoşulur.",
"horizon.extras.ui_sound_reserve.tooltip": "Dünyadakı səslər 247 səs kanalından 16-nı pulsuz buraxır, buna görə də onların selinin (böyük partlayış) menyu kliklərini susdura bilməz.",
"horizon.extras.music_page.tooltip": "Musiqi və Səslər Musiqi... səhifəsi əldə edir: hər hansı daxili treki indi çalın və ya birini söndürün ki, oyun onu seçməyi dayandırsın. Siz treki söndürməyincə heç nə dəyişməyəcək.",
"horizon.extras.mouse_tweaks.tooltip": "Sağa sürükləmək hər yuvaya bir yerləşdirir, sola sürükləmək saxlanılan elementi toplayır, Shift + sola sürükləmək hər bir yuvaya köçür.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Aşağı yuvanın bir elementini digər tərəfə göndərir, yuxarı birini geri gətirir.",
"horizon.extras.sort.tooltip": "İnventar, sandıqlar, çəlləklər, ender sandıqlar və şulker qutularını yaradıcı nişan sırası ilə çeşidləyir, yığınları birləşdirin.",
"horizon.extras.sort_key.tooltip": "Kursorun altındakı hissəni çeşidləyir. Basın, sonra düyməni basın; Escape onu açır.",
"horizon.extras.no_ai.tooltip": "Hər bir AI funksiyasını gizlədir və söndürür: agent nəzarəti, horizon-ctl və MCP serverləri. Başlatıcının açarı da.",
"horizon.extras.agent_control.tooltip": "Öz AI agentiniz bu oyunu horizon-ctl və ya MCP müştəri vasitəsilə yalnız bu kompüterdə oynaya bilər.",
"horizon.extras.discord_presence.tooltip": "Discord işləyərkən Discord profilinizdə Horizon Playing göstərilir: menyular, tək oyunçu və ya multiplayer, ölçü və oynanılan vaxt. Heç vaxt dünyanın adı və ya server ünvanı.",
"Calibrate...": "Kalibrləmə...",
"Calibrate Anyway...": "İstənilən halda kalibrləyin...",
"Use Automatic Setup": "Avtomatik Quraşdırmadan istifadə edin",
"Shortcut: %s": "Qısayol: %s",
"Next": "Sonrakı",
"HDR Calibration %s/%s: %s": "HDR Kalibrləmə %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Aşağıdakı üç tam ekranı, hər dəfə bir sınaq şəklini təyin edir.",
"A key that opens this screen from the game. Not bound at first.": "Oyundan bu ekranı açan açar. Əvvəlcə bağlı deyil.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Ekranınız nə qədər parlaq olduğunu deməyib: onu qurmaq üçün kalibrləyin.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Ekranınız üçün artıq qurulub. Yalnız HDR səhv görünürsə kalibrləyin.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Pik Parlaqlığı və Kağız Ağı ekranınızın quraşdırılmasına qaytarır.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Ekranınız üçün qurun. Səhv görünməsə, Auto-da buraxın.",
"Lower it until the logo just disappears into the black.": "Loqo qara rəngdə yox olana qədər onu endirin.",
"Raise it until the logo just disappears into the bright square.": "Loqo parlaq kvadratda yox olana qədər onu qaldırın.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Kartı oynadığınız işıqda uzun müddət baxmaq üçün rahat olan ağ rəngə çevirin.",
"horizon.splitscreen.profiles.forget": "Unut",
"horizon.splitscreen.profiles.create": "Yaradın",
"horizon.splitscreen.profiles.you": " - sən",
"horizon.splitscreen.profiles.forget_named": "Unut %s",
"horizon.splitscreen.profiles.previous": "Əvvəlki",
"horizon.splitscreen.profiles.next": "Sonrakı",
"horizon.splitscreen.profiles.new_button": "Yeni Profil...",
"horizon.splitscreen.profiles.forget_title": "Profili unutmusunuz?",
"horizon.splitscreen.profiles.forget_leaves": "Bu nümunənin siyahısını tərk edir.",
"horizon.splitscreen.profiles.forget_kept": "Dünya tərəqqisi və parametrlər saxlanılır.",
"horizon.splitscreen.profiles.new_title": "Yeni Profil",
"horizon.splitscreen.profiles.name_hint": "Ad (3-16 hərf, rəqəm, _)",
"horizon.splitscreen.profiles.switch_title": "Profilə keçin",
"horizon.splitscreen.profiles.offline_note": "Oflayn profillər bu instansiyaya aiddir",
"horizon.splitscreen.profiles.move": "Oynadığınız yerdə hərəkət edin",
"horizon.splitscreen.profiles.move_place": "Oynadığınız yerə köçürün: %s",
"horizon.friends.title": "Dostlarla oynayın",
"horizon.friends.hint": "Dostlar kodu başlıq ekranının Dostlar düyməsindən sonra, Birbaşa Bağlantıda və ya başlatma qurğusunun Play səhifəsində yerləşdirin.",
"horizon.friends.copied": "Kopyalandı",
"horizon.friends.copy_code": "Kodu kopyalayın",
"horizon.friends.stop_sharing": "Paylaşmağı dayandırın",
"horizon.friends.none_yet": "Burada hələ dost yoxdur.",
"horizon.friends.here_now": "İndi burada: %s",
"horizon.friends.share_intro": "Bu dünyanı dəvət kodunun arxasında paylaşın: o, LAN-a açılır və internetdəki dostlar kodla qoşulur. Heç bir serverimiz iştirak etmir.",
"horizon.friends.start_sharing": "Paylaşmağa başlayın",
"horizon.friends.troubleshooting": "Problem həll olunur...",
"horizon.friends.stopped": "Dəvət kodunu paylaşmağı dayandırdı.",
"horizon.friends.short_code_changed": "Kimsə səhv qısa kodu sınamağa davam etdi, ona görə də dəyişdi.",
"horizon.friends.wrong_code": "Kimsə səhv kodu sınadı.",
"horizon.friends.code_ready": "Dəvət kodunuz hazırdır: Oyun Menyu, Dostlarla Oyna. Kopyalayın və dostlarınıza göndərin.",
"horizon.friends.share_failed": "Dünyanı paylaşmaq mümkün olmadı: %s",
"horizon.friends.finding_route": "Dostların sizinlə necə əlaqə saxlaya biləcəyi tapılır...",
"horizon.osk.space": "Kosmos",
"horizon.osk.delete": "Sil",
"horizon.osk.type": "Növ",
"horizon.osk.symbols": "Simvollar",
"horizon.osk.close": "Bağlayın",
"horizon.extras.chat_animation.tooltip": "Yeni söhbət xətti bütöv görünmək əvəzinə soldan içəri sürüşür.",
"horizon.extras.chat_indicator.tooltip": "Söhbət xəttinin solunda olan rəngli bar.",
"horizon.extras.wheel_cycles_settings.tooltip": "Parametrlərdəki düymənin üzərindəki sürüşdürmə çarxı vanildə olduğu kimi dəyərini dəyişir. Söndürülmüş, təkər yalnız siyahını sürüşdürür.",
"horizon.extras.pack_browser.tooltip": "Resurs Paketlərini seçin Modrinth-də axtarış aparan və paketlər qovluğuna paket quraşdıran Əlavə et düyməsini alır. Off, oyun Modrinthlə heç vaxt əlaqə saxlamır.",
"horizon.extras.splitscreen.tooltip": "Dünyada başqa bir nəzarətçi yeni oyunçu kimi qoşula bilər: soruşduqda A düyməsini basın, sonra profil və harada oynayacağınızı seçin.",
"horizon.splitscreen.setup.monitor_unknown": "monitor",
"horizon.splitscreen.setup.window": "Pəncərə %s",
"horizon.splitscreen.setup.new_window": "Yeni pəncərə",
"horizon.splitscreen.setup.fill_monitor": "%s doldurun",
"horizon.splitscreen.setup.done": "Bitdi",
"horizon.splitscreen.setup.on": "Aktiv",
"horizon.splitscreen.setup.off": "Söndür",
"horizon.splitscreen.setup.player": "Oyunçu %s",
"horizon.splitscreen.setup.revert": "Geri qaytarın",
"horizon.splitscreen.setup.apply": "Müraciət edin",
"horizon.splitscreen.setup.warning.empty_main": "Window 1-də heç kim oynamır: gizlənir",
"horizon.splitscreen.setup.warning.empty_close": "%s Pəncərəsində heç kim oynamır: Tətbiq edərkən bağlanır",
"horizon.splitscreen.setup.warning.empty_new": "%s pəncərəsində heç kim oynamır: açılmayacaq",
"horizon.splitscreen.setup.warning.on_main": "Pəncərə %s Pəncərə 1-in monitorunu doldurur: onun oyunçuları Pəncərə 1-də oynayır",
"horizon.splitscreen.setup.warning.unplugged": "Pəncərənin %s monitoru bağlı deyil: onun oyunçuları Window 1-də oynayır",
"horizon.splitscreen.setup.status.off": "Splitscreen deaktivdir.",
"horizon.splitscreen.setup.status.enable_hint": "Onu yandırın, sonra dünyanın başqa bir nəzarətçisində düyməni basın.",
"horizon.splitscreen.setup.status.join_hint": "Qoşulmaq və profil seçmək üçün A düyməsini basın.",
"horizon.splitscreen.setup.status.idle": "Splitscreen boş",
"horizon.splitscreen.setup.status.alone": "Başqa yerli oyunçu yoxdur.",
"horizon.splitscreen.setup.status.solo_controls": "Solo nəzarət və nəzarətçi rəyi dəyişməzdir.",
"horizon.splitscreen.setup.window_empty": "Pəncərə %s: boş",
"horizon.splitscreen.setup.note.select": "Oyunçuya klikləyin və ya onu pəncərəyə və ya monitora sürükləyin.",
"horizon.splitscreen.setup.note.apply": "Bu şəkildə oynamaq üçün müraciət edin, ləğv etmək üçün geri qaytarın.",
"horizon.splitscreen.setup.note.place": "%s harada oynamalıdır?",
"horizon.splitscreen.setup.this_player": "bu oyunçu",
"horizon.splitscreen.setup.switch_disabled": "Digərləri ayrıldıqdan sonra Splitscreen sönür (pauza menyusu).",
"horizon.splitscreen.setup.window_place": "Pəncərə %s (%s/%s)",
"horizon.splitscreen.setup.names": "Adlar",
"horizon.splitscreen.setup.identify": "Müəyyən etmək",
"horizon.splitscreen.setup.layout.two": "2 paylaşım: %s",
"horizon.splitscreen.setup.layout.three": "3 paylaşım: %s",
"horizon.splitscreen.setup.layout.two.0": "Avtomatik",
"horizon.splitscreen.setup.layout.two.1": "Yan-yana",
"horizon.splitscreen.setup.layout.two.2": "Üst və alt",
"horizon.splitscreen.setup.layout.three.0": "Üstündə biri geniş",
"horizon.splitscreen.setup.layout.three.1": "Solda biri hündür",
"horizon.splitscreen.setup.layout.three.2": "Yan-yana",
"horizon.splitscreen.setup.help.player": "Onları köçürmək üçün %s üzərinə klikləyin və ya dartın.",
"horizon.splitscreen.setup.help.layout_auto": "%s (pəncərə geniş olduqda yan-yana). Dəyişmək üçün basın.",
"horizon.splitscreen.setup.help.layout": "%s. Dəyişmək üçün basın.",
"horizon.splitscreen.setup.help.switch": "Bu kompüter üçün bölünmüş ekran, aktiv və ya söndürülür.",
"horizon.splitscreen.setup.help.identify": "Hər bir oyunçunun görünüşü onun hansı pəncərədə olduğunu göstərir.",
"horizon.splitscreen.setup.help.names": "Görünüşünün küncündə hər bir oyunçunun adı.",
"horizon.splitscreen.setup.help.revert": "Hələ tətbiq olunmamış dəyişiklikləri geri alın.",
"horizon.splitscreen.setup.help.apply": "Xəritənin göstərdiyi şəkildə oynayın.",
"horizon.splitscreen.setup.help.unchanged": "Hələ heç nə dəyişməyib.",
"horizon.splitscreen.setup.give_up_pad": "Pasxadan imtina et",
"horizon.splitscreen.prompt.controller_connected": "Nəzarətçi qoşulub",
"horizon.splitscreen.prompt.controller_disconnected": "Nəzarətçi ayrıldı",
"horizon.splitscreen.prompt.controller_disconnected.hint": "Onu yenidən birləşdirin və ya digərində düyməni basın",
"horizon.splitscreen.prompt.join.one": "%s qoşulmaq istəyir",
"horizon.splitscreen.prompt.join.many": "%s nəzarətçilər qoşulmaq istəyirlər",
"horizon.splitscreen.prompt.join.yes": "Qoşulun",
"horizon.splitscreen.prompt.join.as_p1": "P1 kimi oynayın",
"horizon.splitscreen.prompt.join.no": "yox",
"horizon.splitscreen.prompt.lost": "%s: nəzarətçi ayrıldı",
"horizon.splitscreen.prompt.lost.use_another": "Başqasını istifadə edin",
"horizon.splitscreen.prompt.lost.leave": "tərk et",
"horizon.splitscreen.setup.help.give_up_pad": "Nəzarətçiniz sizi idarə etməyi dayandırır və ona qoşulmaq təklif olunur (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A onu bura düşür, B onu geri qoyur.",
"horizon.splitscreen.setup.note.held": "%s götürdü: sol/sağ yeri seçir, A düşür, B onu geri qoyur.",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Async-dən kənar yoxlayır; qurumlar işarələyir.",
"Block Entities": "Blok obyektləri",
"Shulker Boxes": "Shulker qutuları",
"Reuses settled boxes; lid movement stays animated.": "Yerləşmiş qutuları təkrar istifadə edir; qapaq hərəkəti canlı qalır.",
"Signs": "İşarələr",
"Reuses sign text until text, light, or font changes.": "Mətn, işıq və ya şrift dəyişənə qədər işarə mətnindən təkrar istifadə edir.",
"Decorated Pots": "Bəzəkli qablar",
"Reuses still pots; break wobble and sherd changes redraw.": "Qazan qabları təkrar istifadə edir; yırğalanmaq və parçalanma dəyişiklikləri yenidən çəkmək.",
"Bells": "Zənglər",
"Reuses still bells; ringing stays animated.": "Hərəkətsiz zəngləri təkrar istifadə edir; Zəng animasiyalı qalır.",
"Chests": "Sinələr",
"Reuses closed chests; moving lids stay animated.": "Qapalı sandıqları təkrar istifadə edir; hərəkət edən qapaqlar canlı qalır.",
"Banners": "Bannerlər",
"Reuses poles; flags and pattern layers still update.": "Dirəkləri təkrar istifadə edir; bayraqlar və naxış qatları hələ də yenilənir.",
"Copper Golem Statues": "Mis Qolem heykəlləri",
"Reuses statue geometry until its block state changes.": "Blok vəziyyəti dəyişənə qədər heykəlin həndəsəsindən təkrar istifadə edir.",
"Leaf Culling": "Yarpaqların kəsilməsi",
"Every leaf face. Vanilla's fancy leaves.": "Hər yarpaq üzü. Vanilin gözəl yarpaqları.",
"Distant Plant Dither": "Uzaq Bitki Dither",
"Keeps fine plants in high-res packs from fading far away.": "Yüksək dəqiqlikli paketlərdəki incə bitkilərin uzaqdan solmasını qarşısını alır.",
"Screenshot Quality": "Ekran Şotunun Keyfiyyəti",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 ekran görüntüləri bu dəfələrlə oyunun həlli. Yüksək daha uzun çəkir.",
"Frame Generation": "Çərçivə Yaradılması",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Hər iki real arasında düzəldilmiş çərçivəni göstərir: daha hamar, bir az daha gecikmə.",
"Shadow Cache": "Kölgə önbelleği",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Günəş, görünüş və dünya uyğunlaşdıqda paket kölgə xəritəsindən təkrar istifadə edir. Defolt olaraq söndürülür.",
"Find BlueMap": "BlueMap tapın",
"On servers, finds their BlueMap web map for far terrain. Next join.": "Serverlərdə uzaq ərazilər üçün BlueMap veb xəritəsini tapır. Növbəti qoşulun.",
"How far around you BlueMap tiles are downloaded. Next join.": "Ətrafınızda BlueMap plitələri nə qədər yüklənir. Növbəti qoşulun.",
"Synchronous": "Sinxron",
"Asynchronous": "Asinxron",
"Solid": "Möhkəm",
"Solid Aggressive": "Möhkəm Aqressiv",
"Hollow": "Boş",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Tam örtünün içindəki yarpaqlar möhkəm çəkilir: daha az keçirici təbəqə.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Bir tərəfi açıq olan yarpaqlar da bərk çəkir: daha sürətli, daha sıx kənarlar.",
"Drops faces between leaves: faster, but gaps look through.": "Yarpaqlar arasında üzləri düşür: daha sürətli, lakin boşluqlar içəri baxır.",
"Hotbar: Previous Slot": "İsti panel: Əvvəlki yuva",
"Hotbar: Next Slot": "İsti panel: Növbəti Slot",
"horizon.agent.connected.one": "Qoşuldu: 1 agent bağlantısı",
"horizon.agent.connected.many": "Qoşuldu: %s agent əlaqələri",
"horizon.agent.connected.one_ago": "Qoşuldu: 1 agent bağlantısı, son əmr %s s əvvəl",
"horizon.agent.connected.many_ago": "Qoşuldu: %s agent əlaqələri, son əmr %s s əvvəl",
"horizon.agent.waiting": "Agent gözləyir.",
"horizon.agent.off": "Off: agentlər bu oyuna daxil ola bilməz.",
"horizon.agent.session_opened": "Oyuna başlayan proqram tərəfindən bu sessiya üçün açıldı.",
"horizon.agent.socket": "Soket: %s (tam yolu üçün təfərrüatları kopyalayın)",
"horizon.agent.local_only": "Yalnız yerli: bu kompüterdəki agentlər, heç vaxt şəbəkə.",
"Auto (%s nits)": "Avtomatik (%s nits)",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Modelləri qeyri-şəffaf blokların arxasında gizlədir. Müəssisələrin yenilənməsi. Fərdi paketlərlə endirim.",
"horizon.extras.repro_keys": "Qısayolların qeyd edilməsi",
"horizon.extras.repro_keys.tooltip": "F3+R repro qeydini başlayır və ya dayandırır və F3+E öz paketini yazır. Defolt olaraq söndürülür: yazı dünyanı kopyalayır.",
"horizon.agent.record_session": "Səs yazmağa başlayın",
"horizon.agent.record_stop": "Səs yazmağı dayandırın",
"horizon.agent.export_repro": "Repro paketini ixrac edin",
"horizon.repro.started": "Qeydiyyat başladı; F3+R onu dayandırır və paketi yazır.",
"horizon.repro.stopped": "Qeydiyyat dayandırıldı: %s",
"horizon.repro.bundle": "Repro paketi: %s",
"horizon.repro.error": "Reproduksiya: %s",
"horizon.repro.no_recording": "İxrac etmək üçün hələ heç bir qeyd yoxdur.",
"horizon.repro.replay_started": "Təkrar başladı: %s",
"horizon.pack.addInfo": "Modrinth-dən resurs paketi əlavə edin",
"horizon.pack.addWorking": "Modrinthdən soruşur...",
"horizon.pack.addNone": "Heç bir resurs paketi tapılmadı",
"horizon.pack.addTitle": "Resurs Paketi əlavə edin",
"horizon.pack.install": "Quraşdırın",
"horizon.pack.installing": "Quraşdırılır",
"horizon.pack.installed": "Quraşdırılıb",
"horizon.pack.search": "Axtar",
"horizon.pack.searchHint": "Modrinth axtar...",
"horizon.pack.filters": "Filtrlər",
"horizon.pack.filter.realistic": "Realist",
"horizon.pack.filter.simplistic": "Sadə",
"horizon.pack.filter.vanillaLike": "Vanil kimi",
"horizon.pack.addFailed": "Paketi əlavə etmək mümkün olmadı: %s",
"horizon.extras.header.cosmetics": "Kosmetika",
"horizon.extras.cosmetics_mine": "Kosmetikam",
"horizon.extras.cosmetics_mine.tooltip": "Gizli: hər Horizon oyunçu sizi kosmetikanız olmadan görür. Başlatma şkafı ilə eyni açar.",
"horizon.extras.cosmetics_others": "Digər oyunçuların kosmetikası",
"horizon.extras.cosmetics_others.tooltip": "Digər Horizon oyunçular nə geyinirlər: hər şey, yalnız ad etiketi üslubları və ya heç nə. Gizli, oyun heç vaxt nə geyindiklərini soruşmur.",
"horizon.extras.cosmetics_auras": "Başqalarının auraları və izləri",
"horizon.extras.cosmetics_auras.tooltip": "Digər oyunçuların geydiyi auralar və izlər. Defolt olaraq söndürülür.",
"horizon.extras.cosmetics.shown": "Göstərildi",
"horizon.extras.cosmetics.hidden": "Gizli",
"horizon.extras.cosmetics.name_tags": "Yalnız ad etiketləri"
}
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff. Show more