mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 10:31:38 -10:00
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:
commit
c2aa51c15f
611 files changed
+133355
-3559
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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 |
|
||||
| --- | ---: | --- |
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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(®ion, 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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", ¯os);
|
||||
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, ¯os, &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();
|
||||
}
|
||||
@@ -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, ¯os, &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, ¯os, &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, ¯os, &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, ¯os, &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, ¯os, &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,
|
||||
|
||||
@@ -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, ©, [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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(®ion, 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,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(®ular));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user